From 51febb68d2567ec3964b48fd4c93c75c2ffd1a8f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 15:55:53 -0300 Subject: [PATCH 01/74] feat: establish semantic C3 contracts and baseline (#62) --- CHANGELOG.md | 3 + docs/semantic-c3-baseline.md | 61 ++++ .../Contracts/SemanticC3ContractJson.cs | 104 ++++++ .../Contracts/SemanticC3Contracts.cs | 72 ++++ .../Contracts/SemanticC3Validator.cs | 336 ++++++++++++++++++ src/Repo2C4.Core/Contracts/StableIds.cs | 29 ++ .../Repo2C4.Core.Tests.csproj | 3 + .../SemanticC3BaselineTests.cs | 49 +++ .../SemanticC3ContractTests.cs | 210 +++++++++++ ...t-observability-lab.ingestion.v1.1.c2.json | 131 +++++++ 10 files changed, 998 insertions(+) create mode 100644 docs/semantic-c3-baseline.md create mode 100644 src/Repo2C4.Core/Contracts/SemanticC3ContractJson.cs create mode 100644 src/Repo2C4.Core/Contracts/SemanticC3Contracts.cs create mode 100644 src/Repo2C4.Core/Contracts/SemanticC3Validator.cs create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3BaselineTests.cs create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3ContractTests.cs create mode 100644 tests/fixtures/semantic-c3/dotnet-observability-lab.ingestion.v1.1.c2.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 613a2da5..ad038236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] +### Added +- Additive, versioned Semantic C3 contract foundation with deterministic component taxonomy, stable source-symbol identity, reviewable internal/external relations, conceptual multi-container selection, and a reproducible v1.1.0 dogfooding baseline. + ## [1.1.0] - 2026-10-02 ### Added diff --git a/docs/semantic-c3-baseline.md b/docs/semantic-c3-baseline.md new file mode 100644 index 00000000..72539719 --- /dev/null +++ b/docs/semantic-c3-baseline.md @@ -0,0 +1,61 @@ +# Semantic C3 v1.1.0 baseline + +This note freezes the quality baseline used to start Phase 9 without changing the stable C1/C2 v1 contract. + +## Reproducible fixture + +The repository-local fixture +`tests/fixtures/semantic-c3/dotnet-observability-lab.ingestion.v1.1.c2.json` +mirrors the architectural shape observed while dogfooding `Ingestion.Api` in +`dotnet-observability-lab`. It is intentionally synthetic and bounded so the +test suite does not depend on another repository or execute application code. + +Run only the baseline test with: + +```bash +dotnet test tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj \ + --filter FullyQualifiedName~SemanticC3BaselineTests +``` + +## v1.1.0 behavior + +For the selected `el_ingestion_api` container, the current +`ArchitectureC3Builder` groups evidence into these generic proposals: + +```text +HTTP interface +Integration adapter +Application dependency +``` + +The baseline also preserves evidence-backed relations from the generic +integration adapter to the existing PostgreSQL and Redis C1/C2 elements. +All proposals remain `requiresReview`, and repeated generation must keep the +same component and relation IDs. + +## Semantic target + +Phase 9 is expected to improve architectural responsibility resolution toward +an equivalent structure such as: + +```text +HTTP ingestion endpoint + | + v +Idempotent ingestion use case + | + v +Transactional persistence + | + +--> PostgreSQL + +--> Redis +``` + +Textual equality with a manually maintained C3 is not required. Improvement is +demonstrated when the generated proposal has equivalent architectural +responsibilities, every promoted component/relation has provenance, ambiguous +boundaries remain reviewable, and no unsupported component is fabricated. + +This baseline deliberately does not implement semantic detection. It records +the starting point so issues #63-#69 can prove material improvement against a +stable reference. diff --git a/src/Repo2C4.Core/Contracts/SemanticC3ContractJson.cs b/src/Repo2C4.Core/Contracts/SemanticC3ContractJson.cs new file mode 100644 index 00000000..2acb631c --- /dev/null +++ b/src/Repo2C4.Core/Contracts/SemanticC3ContractJson.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Repo2C4.Core.Contracts; + +/// Canonical deterministic JSON for the additive Semantic C3 contract. +public static class SemanticC3ContractJson +{ + private static readonly JsonSerializerOptions Options = CreateOptions(); + + public static string Serialize(SemanticC3Proposal proposal) + { + ArgumentNullException.ThrowIfNull(proposal); + ThrowIfInvalid(SemanticC3Validator.Validate(proposal)); + return JsonSerializer.Serialize(Canonical(proposal), Options); + } + + public static SemanticC3Proposal Deserialize(string json) + { + ArgumentNullException.ThrowIfNull(json); + + try + { + SemanticC3Proposal? proposal = JsonSerializer.Deserialize(json, Options); + ThrowIfInvalid(SemanticC3Validator.Validate(proposal)); + return Canonical(proposal!); + } + catch (JsonException) + { + throw new ContractValidationException( + [ + new ContractError( + "json.invalid", + "$", + "JSON cannot be deserialized into the Semantic C3 contract."), + ]) + { + Source = nameof(SemanticC3ContractJson), + }; + } + } + + private static SemanticC3Proposal Canonical(SemanticC3Proposal proposal) => + proposal with + { + SelectedContainerIds = SortIds(proposal.SelectedContainerIds), + Components = + [ + .. proposal.Components + .OrderBy(component => component.Id, StringComparer.Ordinal) + .Select(component => component with + { + EvidenceIds = SortIds(component.EvidenceIds), + }), + ], + Relations = + [ + .. proposal.Relations + .OrderBy(relation => relation.Id, StringComparer.Ordinal) + .Select(relation => relation with + { + EvidenceIds = SortIds(relation.EvidenceIds), + }), + ], + }; + + private static ImmutableArray SortIds(ImmutableArray ids) => + [.. ids.OrderBy(id => id, StringComparer.Ordinal)]; + + private static void ThrowIfInvalid(ImmutableArray errors) + { + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + } + + private static JsonSerializerOptions CreateOptions() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + options.Converters.Add( + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false)); + options.Converters.Add( + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false)); + options.Converters.Add( + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false)); + + return options; + } +} diff --git a/src/Repo2C4.Core/Contracts/SemanticC3Contracts.cs b/src/Repo2C4.Core/Contracts/SemanticC3Contracts.cs new file mode 100644 index 00000000..1f2733bf --- /dev/null +++ b/src/Repo2C4.Core/Contracts/SemanticC3Contracts.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; + +namespace Repo2C4.Core.Contracts; + +/// Version of the additive Semantic C3 contract. It is independent from the public C1/C2 v1 schema. +public static class SemanticC3ContractSchema +{ + public const string Version = "1.0"; +} + +/// Architectural responsibility proposed for a semantically relevant .NET component candidate. +public enum SemanticC3ComponentCategory +{ + HttpEndpoint, + ApplicationService, + BackgroundWorker, + MessagingConsumer, + MessagingPublisher, + PersistenceAdapter, + IntegrationAdapter, +} + +/// Identifies whether a C3 relation targets another component or an existing C1/C2 element. +public enum SemanticC3RelationTargetKind +{ + Component, + ArchitectureElement, +} + +/// +/// Stable source-symbol identity. ProjectPath is repository-relative and SymbolId is semantic, not line-based. +/// +public sealed record SemanticC3SourceSymbolIdentity( + string Id, + string ProjectPath, + string SymbolId); + +/// +/// Evidence-first component proposal. A source symbol is optional because some candidates may aggregate multiple facts. +/// +public sealed record SemanticC3ComponentCandidate( + string Id, + string ContainerId, + SemanticC3ComponentCategory Category, + string Name, + string Responsibility, + ImmutableArray EvidenceIds, + SemanticC3SourceSymbolIdentity? SourceSymbol, + ReviewStatus Status, + string? ReviewReason); + +/// +/// Directed relation from a component candidate to another component or to an existing C1/C2 element. +/// +public sealed record SemanticC3RelationCandidate( + string Id, + string SourceComponentId, + string DestinationId, + SemanticC3RelationTargetKind DestinationKind, + string Description, + ImmutableArray EvidenceIds, + ReviewStatus Status, + string? ReviewReason); + +/// +/// Versioned Semantic C3 proposal for one or more explicitly selected C2 containers. +/// +public sealed record SemanticC3Proposal( + string SchemaVersion, + ImmutableArray SelectedContainerIds, + ImmutableArray Components, + ImmutableArray Relations); diff --git a/src/Repo2C4.Core/Contracts/SemanticC3Validator.cs b/src/Repo2C4.Core/Contracts/SemanticC3Validator.cs new file mode 100644 index 00000000..a44aa453 --- /dev/null +++ b/src/Repo2C4.Core/Contracts/SemanticC3Validator.cs @@ -0,0 +1,336 @@ +using System.Collections.Immutable; + +namespace Repo2C4.Core.Contracts; + +/// Validates the additive Semantic C3 contract without changing the stable C1/C2 v1 contract. +public static class SemanticC3Validator +{ + public static ImmutableArray Validate(SemanticC3Proposal? proposal) + { + List errors = []; + + if (proposal is null) + { + errors.Add(new ContractError("semanticC3.modelMissing", "$", "Semantic C3 proposal is required.")); + return [.. errors]; + } + + if (proposal.SchemaVersion != SemanticC3ContractSchema.Version) + { + errors.Add(new ContractError( + "schema.unsupported", + "$.schemaVersion", + "Only Semantic C3 schema version " + SemanticC3ContractSchema.Version + " is supported.")); + } + + if (proposal.SelectedContainerIds.IsDefault || + proposal.Components.IsDefault || + proposal.Relations.IsDefault) + { + errors.Add(new ContractError( + "collection.missing", + "$", + "Semantic C3 selected containers, components and relations must be initialized collections.")); + return [.. errors]; + } + + HashSet selectedContainers = ValidateSelection(proposal.SelectedContainerIds, errors); + HashSet componentIds = ValidateComponents(proposal.Components, selectedContainers, errors); + ValidateRelations(proposal.Relations, componentIds, errors); + + return [.. errors]; + } + + private static HashSet ValidateSelection( + ImmutableArray selectedContainerIds, + List errors) + { + HashSet selected = new(StringComparer.Ordinal); + + if (selectedContainerIds.IsEmpty) + { + errors.Add(new ContractError( + "semanticC3.selectionMissing", + "$.selectedContainerIds", + "At least one C2 container must be selected for Semantic C3.")); + return selected; + } + + for (int i = 0; i < selectedContainerIds.Length; i++) + { + string containerId = selectedContainerIds[i]; + string path = "$.selectedContainerIds[" + i + "]"; + + if (string.IsNullOrWhiteSpace(containerId)) + { + errors.Add(new ContractError("id.required", path, "Selected container ID is required.")); + continue; + } + + if (!selected.Add(containerId)) + { + errors.Add(new ContractError( + "semanticC3.selectionDuplicate", + path, + "Selected Semantic C3 container IDs must be unique.")); + } + } + + return selected; + } + + private static HashSet ValidateComponents( + ImmutableArray components, + HashSet selectedContainers, + List errors) + { + HashSet componentIds = new(StringComparer.Ordinal); + + for (int i = 0; i < components.Length; i++) + { + SemanticC3ComponentCandidate? component = components[i]; + string path = "$.components[" + i + "]"; + + if (component is null) + { + errors.Add(new ContractError( + "semanticC3.componentMissing", + path, + "Semantic C3 component candidate must not be null.")); + continue; + } + + if (string.IsNullOrWhiteSpace(component.Id)) + { + errors.Add(new ContractError("id.required", path + ".id", "Component ID is required.")); + } + else if (!componentIds.Add(component.Id)) + { + errors.Add(new ContractError("id.duplicate", path + ".id", "Component IDs must be unique.")); + } + + if (string.IsNullOrWhiteSpace(component.ContainerId) || + !selectedContainers.Contains(component.ContainerId)) + { + errors.Add(new ContractError( + "semanticC3.containerScope", + path + ".containerId", + "Every component must belong to one of the explicitly selected containers.")); + } + + if (!Enum.IsDefined(component.Category)) + { + errors.Add(new ContractError( + "semanticC3.category", + path + ".category", + "Unknown Semantic C3 component category.")); + } + + if (string.IsNullOrWhiteSpace(component.Name) || string.IsNullOrWhiteSpace(component.Responsibility)) + { + errors.Add(new ContractError( + "text.required", + path, + "Component name and responsibility are required.")); + } + + ValidateSourceSymbol(component.SourceSymbol, path + ".sourceSymbol", errors); + ValidateProvenance( + component.EvidenceIds, + component.Status, + component.ReviewReason, + path, + errors); + } + + return componentIds; + } + + private static void ValidateRelations( + ImmutableArray relations, + HashSet componentIds, + List errors) + { + HashSet relationIds = new(StringComparer.Ordinal); + + for (int i = 0; i < relations.Length; i++) + { + SemanticC3RelationCandidate? relation = relations[i]; + string path = "$.relations[" + i + "]"; + + if (relation is null) + { + errors.Add(new ContractError( + "semanticC3.relationMissing", + path, + "Semantic C3 relation candidate must not be null.")); + continue; + } + + if (string.IsNullOrWhiteSpace(relation.Id)) + { + errors.Add(new ContractError("id.required", path + ".id", "Relation ID is required.")); + } + else if (!relationIds.Add(relation.Id)) + { + errors.Add(new ContractError("id.duplicate", path + ".id", "Relation IDs must be unique.")); + } + + if (string.IsNullOrWhiteSpace(relation.SourceComponentId) || + !componentIds.Contains(relation.SourceComponentId)) + { + errors.Add(new ContractError( + "relation.sourceMissing", + path + ".sourceComponentId", + "Semantic C3 relation source must be an existing component candidate.")); + } + + if (!Enum.IsDefined(relation.DestinationKind)) + { + errors.Add(new ContractError( + "semanticC3.destinationKind", + path + ".destinationKind", + "Unknown Semantic C3 relation destination kind.")); + } + else if (relation.DestinationKind == SemanticC3RelationTargetKind.Component && + (string.IsNullOrWhiteSpace(relation.DestinationId) || + !componentIds.Contains(relation.DestinationId))) + { + errors.Add(new ContractError( + "relation.destinationMissing", + path + ".destinationId", + "Component relation destination must be an existing component candidate.")); + } + else if (relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement && + string.IsNullOrWhiteSpace(relation.DestinationId)) + { + errors.Add(new ContractError( + "relation.destinationMissing", + path + ".destinationId", + "External relation destination must identify an existing C1/C2 element.")); + } + + if (string.IsNullOrWhiteSpace(relation.Description)) + { + errors.Add(new ContractError( + "text.required", + path + ".description", + "Relation description is required.")); + } + + ValidateProvenance( + relation.EvidenceIds, + relation.Status, + relation.ReviewReason, + path, + errors); + } + } + + private static void ValidateSourceSymbol( + SemanticC3SourceSymbolIdentity? sourceSymbol, + string path, + List errors) + { + if (sourceSymbol is null) + { + return; + } + + bool validPath = ContractValidator.IsNormalizedRelativePath(sourceSymbol.ProjectPath); + if (!validPath) + { + errors.Add(new ContractError( + "semanticC3.sourceSymbolPath", + path + ".projectPath", + "Source symbol project path must be a normalized repository-relative path.")); + } + + if (string.IsNullOrWhiteSpace(sourceSymbol.SymbolId)) + { + errors.Add(new ContractError( + "semanticC3.sourceSymbolIdentity", + path + ".symbolId", + "Source symbol semantic identity is required.")); + } + + if (string.IsNullOrWhiteSpace(sourceSymbol.Id)) + { + errors.Add(new ContractError("id.required", path + ".id", "Source symbol ID is required.")); + } + else if (validPath && !string.IsNullOrWhiteSpace(sourceSymbol.SymbolId)) + { + string expectedId = StableIds.ForSemanticC3SourceSymbol( + sourceSymbol.ProjectPath, + sourceSymbol.SymbolId); + + if (!string.Equals(sourceSymbol.Id, expectedId, StringComparison.Ordinal)) + { + errors.Add(new ContractError( + "semanticC3.sourceSymbolId", + path + ".id", + "Source symbol ID must be derived from its stable project path and semantic symbol identity.")); + } + } + } + + private static void ValidateProvenance( + ImmutableArray evidenceIds, + ReviewStatus status, + string? reviewReason, + string path, + List errors) + { + if (evidenceIds.IsDefault) + { + errors.Add(new ContractError( + "collection.missing", + path + ".evidenceIds", + "Evidence IDs must be initialized.")); + return; + } + + if (!Enum.IsDefined(status)) + { + errors.Add(new ContractError("review.status", path + ".status", "Unknown review status.")); + } + + if (status == ReviewStatus.Confirmed && evidenceIds.IsEmpty) + { + errors.Add(new ContractError( + "review.unsubstantiated", + path + ".status", + "Confirmed Semantic C3 assertions must reference supporting evidence.")); + } + + if (status == ReviewStatus.RequiresReview && string.IsNullOrWhiteSpace(reviewReason)) + { + errors.Add(new ContractError( + "review.reason", + path + ".reviewReason", + "Semantic C3 assertions requiring review must explain why.")); + } + + HashSet seen = new(StringComparer.Ordinal); + for (int i = 0; i < evidenceIds.Length; i++) + { + string evidenceId = evidenceIds[i]; + string evidencePath = path + ".evidenceIds[" + i + "]"; + + if (string.IsNullOrWhiteSpace(evidenceId)) + { + errors.Add(new ContractError( + "id.required", + evidencePath, + "Evidence ID is required.")); + } + else if (!seen.Add(evidenceId)) + { + errors.Add(new ContractError( + "evidence.duplicateReference", + evidencePath, + "Evidence must not be referenced twice by one Semantic C3 assertion.")); + } + } + } +} diff --git a/src/Repo2C4.Core/Contracts/StableIds.cs b/src/Repo2C4.Core/Contracts/StableIds.cs index be54847f..b907e530 100644 --- a/src/Repo2C4.Core/Contracts/StableIds.cs +++ b/src/Repo2C4.Core/Contracts/StableIds.cs @@ -49,6 +49,35 @@ public static string ForRelation(string sourceId, string destinationId, string s return Hash("rel", sourceId, destinationId, stableLogicalKey); } + public static string ForSemanticC3SourceSymbol(string projectPath, string symbolId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(symbolId); + if (!ContractValidator.IsNormalizedRelativePath(projectPath)) + { + throw new ArgumentException( + "Source symbol project path must be a normalized repository-relative path.", + nameof(projectPath)); + } + + return Hash("sym", projectPath, symbolId); + } + + public static string ForSemanticC3Component( + string containerId, + SemanticC3ComponentCategory category, + string stableLogicalKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(containerId); + ArgumentException.ThrowIfNullOrWhiteSpace(stableLogicalKey); + + if (!Enum.IsDefined(category)) + { + throw new ArgumentOutOfRangeException(nameof(category)); + } + + return Hash("cmp", containerId, category.ToString(), stableLogicalKey); + } + private static string Hash(string prefix, params string[] parts) { // Length-prefix fields so ["ab", "c"] cannot alias ["a", "bc"]. diff --git a/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj b/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj index 98a4213c..974c32bd 100644 --- a/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj +++ b/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj @@ -30,6 +30,9 @@ + diff --git a/tests/Repo2C4.Core.Tests/SemanticC3BaselineTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3BaselineTests.cs new file mode 100644 index 00000000..98752800 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3BaselineTests.cs @@ -0,0 +1,49 @@ +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3BaselineTests +{ + [Fact] + public void V110DogfoodEquivalentCapturesGenericC3Baseline() + { + ArchitectureModel c2 = LoadModel("dotnet-observability-lab.ingestion.v1.1.c2.json"); + + ArchitectureC3Model c3 = ArchitectureC3Builder.Build(c2, "el_ingestion_api"); + ArchitectureC3Model repeated = ArchitectureC3Builder.Build(c2, "el_ingestion_api"); + + Assert.Equal( + ["Application dependency", "HTTP interface", "Integration adapter"], + c3.Components + .Select(component => component.Name) + .OrderBy(name => name, StringComparer.Ordinal)); + + Assert.All(c3.Components, component => Assert.Equal(ReviewStatus.RequiresReview, component.Status)); + Assert.Contains(c3.Relations, relation => relation.DestinationId == "el_postgresql"); + Assert.Contains(c3.Relations, relation => relation.DestinationId == "el_redis"); + Assert.Equal( + c3.Components.Select(component => component.Id), + repeated.Components.Select(component => component.Id)); + Assert.Equal( + c3.Relations.Select(relation => relation.Id), + repeated.Relations.Select(relation => relation.Id)); + + Assert.DoesNotContain( + c3.Components, + component => component.Name == "HTTP ingestion endpoint"); + Assert.DoesNotContain( + c3.Components, + component => component.Name == "Idempotent ingestion use case"); + Assert.DoesNotContain( + c3.Components, + component => component.Name == "Transactional persistence"); + } + + private static ArchitectureModel LoadModel(string fileName) + { + string path = Path.Combine(AppContext.BaseDirectory, "SemanticC3Fixtures", fileName); + return ContractJson.DeserializeModel(File.ReadAllText(path)); + } +} diff --git a/tests/Repo2C4.Core.Tests/SemanticC3ContractTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3ContractTests.cs new file mode 100644 index 00000000..08d19b10 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3ContractTests.cs @@ -0,0 +1,210 @@ +using Repo2C4.Core.Contracts; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3ContractTests +{ + [Fact] + public void ProposalRoundTripIsCanonicalAndDeterministic() + { + SemanticC3Proposal proposal = CreateProposal(); + SemanticC3Proposal reordered = proposal with + { + SelectedContainerIds = [.. proposal.SelectedContainerIds.Reverse()], + Components = [.. proposal.Components.Reverse()], + Relations = [.. proposal.Relations.Reverse()], + }; + + string canonical = SemanticC3ContractJson.Serialize(proposal); + string reorderedJson = SemanticC3ContractJson.Serialize(reordered); + SemanticC3Proposal restored = SemanticC3ContractJson.Deserialize(canonical); + + Assert.Equal(canonical, reorderedJson); + Assert.Equal(canonical, SemanticC3ContractJson.Serialize(restored)); + Assert.Equal( + ["el_ingestion_api", "el_worker"], + restored.SelectedContainerIds); + } + + [Fact] + public void TaxonomyUsesStableSemanticCategoryNames() + { + SemanticC3ComponentCategory[] categories = Enum.GetValues(); + SemanticC3ComponentCandidate[] components = + [ + .. categories.Select((category, index) => new SemanticC3ComponentCandidate( + "cmp_" + index, + "el_ingestion_api", + category, + category.ToString(), + "Architecturally relevant responsibility.", + ["ev_" + index], + null, + ReviewStatus.RequiresReview, + "Semantic boundary requires review.")), + ]; + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + ["el_ingestion_api"], + [.. components], + []); + + string json = SemanticC3ContractJson.Serialize(proposal); + + Assert.Contains("\"category\": \"httpEndpoint\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"applicationService\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"backgroundWorker\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"messagingConsumer\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"messagingPublisher\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"persistenceAdapter\"", json, StringComparison.Ordinal); + Assert.Contains("\"category\": \"integrationAdapter\"", json, StringComparison.Ordinal); + } + + [Fact] + public void SourceSymbolIdentityIsStableAndLineIndependent() + { + const string projectPath = "src/Ingestion.Api/Ingestion.Api.csproj"; + const string symbolId = "M:Ingestion.Api.Endpoints.MapIngestion(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)"; + + string first = StableIds.ForSemanticC3SourceSymbol(projectPath, symbolId); + string repeated = StableIds.ForSemanticC3SourceSymbol(projectPath, symbolId); + + Assert.Equal(first, repeated); + Assert.StartsWith("sym_", first, StringComparison.Ordinal); + Assert.NotEqual( + first, + StableIds.ForSemanticC3SourceSymbol( + projectPath, + "M:Ingestion.Api.Endpoints.MapHealth(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)")); + } + + [Fact] + public void ContractRepresentsInternalAndExternalRelations() + { + SemanticC3Proposal proposal = CreateProposal(); + + Assert.Empty(SemanticC3Validator.Validate(proposal)); + Assert.Contains( + proposal.Relations, + relation => relation.DestinationKind == SemanticC3RelationTargetKind.Component); + Assert.Contains( + proposal.Relations, + relation => relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement); + } + + [Fact] + public void InvalidScopeAndSourceSymbolIdentityAreRejected() + { + SemanticC3Proposal proposal = CreateProposal(); + SemanticC3ComponentCandidate first = proposal.Components[0] with + { + ContainerId = "el_not_selected", + SourceSymbol = proposal.Components[0].SourceSymbol! with + { + Id = "sym_wrong", + }, + }; + + SemanticC3Proposal invalid = proposal with + { + Components = + [ + first, + .. proposal.Components.Skip(1), + ], + }; + + IReadOnlyList errors = SemanticC3Validator.Validate(invalid); + + Assert.Contains(errors, error => error.Code == "semanticC3.containerScope"); + Assert.Contains(errors, error => error.Code == "semanticC3.sourceSymbolId"); + } + + [Fact] + public void ExistingV1ModelJsonRemainsIndependentFromSemanticC3() + { + string path = Path.Combine(AppContext.BaseDirectory, "MappingFixtures", "acme.c2.v1.json"); + ArchitectureModel model = ContractJson.DeserializeModel(File.ReadAllText(path)); + + string json = ContractJson.SerializeModel(model); + + Assert.DoesNotContain("selectedContainerIds", json, StringComparison.Ordinal); + Assert.DoesNotContain("sourceSymbol", json, StringComparison.Ordinal); + Assert.DoesNotContain("httpEndpoint", json, StringComparison.Ordinal); + Assert.Equal(json, ContractJson.SerializeModel(ContractJson.DeserializeModel(json))); + } + + private static SemanticC3Proposal CreateProposal() + { + const string containerId = "el_ingestion_api"; + SemanticC3SourceSymbolIdentity endpointSymbol = new( + StableIds.ForSemanticC3SourceSymbol( + "src/Ingestion.Api/Ingestion.Api.csproj", + "M:Ingestion.Api.Endpoints.MapIngestion(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)"), + "src/Ingestion.Api/Ingestion.Api.csproj", + "M:Ingestion.Api.Endpoints.MapIngestion(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)"); + + SemanticC3SourceSymbolIdentity serviceSymbol = new( + StableIds.ForSemanticC3SourceSymbol( + "src/Ingestion.Application/Ingestion.Application.csproj", + "T:Ingestion.Application.IngestEventHandler"), + "src/Ingestion.Application/Ingestion.Application.csproj", + "T:Ingestion.Application.IngestEventHandler"); + + SemanticC3ComponentCandidate endpoint = new( + StableIds.ForSemanticC3Component( + containerId, + SemanticC3ComponentCategory.HttpEndpoint, + endpointSymbol.Id), + containerId, + SemanticC3ComponentCategory.HttpEndpoint, + "HTTP ingestion endpoint", + "Accepts ingestion requests and maps them into the application flow.", + ["ev_http", "ev_route"], + endpointSymbol, + ReviewStatus.RequiresReview, + "Endpoint boundary is evidence-backed but remains a proposal."); + + SemanticC3ComponentCandidate application = new( + StableIds.ForSemanticC3Component( + containerId, + SemanticC3ComponentCategory.ApplicationService, + serviceSymbol.Id), + containerId, + SemanticC3ComponentCategory.ApplicationService, + "Idempotent ingestion use case", + "Coordinates idempotent event ingestion.", + ["ev_handler"], + serviceSymbol, + ReviewStatus.RequiresReview, + "Application responsibility requires review."); + + SemanticC3RelationCandidate internalRelation = new( + StableIds.ForRelation(endpoint.Id, application.Id, "semantic-c3|dispatch"), + endpoint.Id, + application.Id, + SemanticC3RelationTargetKind.Component, + "Dispatches ingestion", + ["ev_dispatch"], + ReviewStatus.RequiresReview, + "Static collaboration evidence does not independently prove runtime flow."); + + SemanticC3RelationCandidate externalRelation = new( + StableIds.ForRelation(application.Id, "el_postgresql", "semantic-c3|persistence"), + application.Id, + "el_postgresql", + SemanticC3RelationTargetKind.ArchitectureElement, + "Persists ingestion state", + ["ev_persistence"], + ReviewStatus.RequiresReview, + "External target is an existing C1/C2 element; runtime flow remains reviewable."); + + return new SemanticC3Proposal( + SemanticC3ContractSchema.Version, + ["el_worker", containerId], + [application, endpoint], + [externalRelation, internalRelation]); + } +} diff --git a/tests/fixtures/semantic-c3/dotnet-observability-lab.ingestion.v1.1.c2.json b/tests/fixtures/semantic-c3/dotnet-observability-lab.ingestion.v1.1.c2.json new file mode 100644 index 00000000..f578c10d --- /dev/null +++ b/tests/fixtures/semantic-c3/dotnet-observability-lab.ingestion.v1.1.c2.json @@ -0,0 +1,131 @@ +{ + "schemaVersion": "1.0", + "level": "C2", + "snapshot": { + "schemaVersion": "1.0", + "repositoryId": "dotnet_observability_lab_ingestion_baseline", + "files": [ + { + "relativePath": "src/Ingestion.Api/Ingestion.Api.csproj", + "sizeBytes": 640 + }, + { + "relativePath": "src/Ingestion.Api/Program.cs", + "sizeBytes": 420 + } + ], + "evidence": [ + { + "id": "ev_application_reference", + "category": "dotnet.project.reference", + "relativePath": "src/Ingestion.Api/Ingestion.Api.csproj", + "line": 8, + "sourceType": "projectFile", + "description": "Project declares a build-time reference to the ingestion application project." + }, + { + "id": "ev_http_candidate", + "category": "dotnet.runtime.http.candidate", + "relativePath": "src/Ingestion.Api/Program.cs", + "line": 12, + "sourceType": "sourceCode", + "description": "Source contains a recognizable HTTP host or route candidate." + }, + { + "id": "ev_ingestion_project", + "category": "dotnet.project", + "relativePath": "src/Ingestion.Api/Ingestion.Api.csproj", + "line": 1, + "sourceType": "projectFile", + "description": "MSBuild project manifest is present." + }, + { + "id": "ev_postgresql_candidate", + "category": "dotnet.integration.postgresql.candidate", + "relativePath": "src/Ingestion.Api/Ingestion.Api.csproj", + "line": 10, + "sourceType": "projectFile", + "description": "Package evidence suggests PostgreSQL integration without proving runtime communication." + }, + { + "id": "ev_redis_candidate", + "category": "dotnet.integration.redis.candidate", + "relativePath": "src/Ingestion.Api/Ingestion.Api.csproj", + "line": 11, + "sourceType": "projectFile", + "description": "Package evidence suggests Redis integration without proving runtime communication." + } + ], + "diagnostics": [] + }, + "elements": [ + { + "id": "el_observability_lab", + "kind": "softwareSystem", + "name": "Observability Lab", + "evidenceIds": [ + "ev_ingestion_project" + ], + "status": "requiresReview", + "reviewReason": "The focal software-system boundary is a reviewable architectural decision." + }, + { + "id": "el_ingestion_api", + "kind": "container", + "name": "Ingestion.Api", + "parentId": "el_observability_lab", + "evidenceIds": [ + "ev_http_candidate", + "ev_ingestion_project" + ], + "status": "requiresReview", + "reviewReason": "Executable HTTP evidence supports a container candidate; deployment boundary remains reviewable." + }, + { + "id": "el_postgresql", + "kind": "container", + "name": "PostgreSQL", + "parentId": "el_observability_lab", + "evidenceIds": [ + "ev_postgresql_candidate" + ], + "status": "requiresReview", + "reviewReason": "Static package evidence does not prove an owned or deployed database container." + }, + { + "id": "el_redis", + "kind": "container", + "name": "Redis", + "parentId": "el_observability_lab", + "evidenceIds": [ + "ev_redis_candidate" + ], + "status": "requiresReview", + "reviewReason": "Static package evidence does not prove an owned or deployed cache container." + } + ], + "relations": [ + { + "id": "rel_ingestion_postgresql", + "sourceId": "el_ingestion_api", + "destinationId": "el_postgresql", + "description": "Reads and writes ingestion data", + "evidenceIds": [ + "ev_postgresql_candidate" + ], + "status": "requiresReview", + "reviewReason": "Package evidence does not independently prove runtime database communication." + }, + { + "id": "rel_ingestion_redis", + "sourceId": "el_ingestion_api", + "destinationId": "el_redis", + "description": "Uses idempotency/cache state", + "evidenceIds": [ + "ev_redis_candidate" + ], + "status": "requiresReview", + "reviewReason": "Package evidence does not independently prove runtime cache communication." + } + ] +} From 98a5faf0ba57c26cd6c779012b3015c36f8cd625 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:26:07 -0300 Subject: [PATCH 02/74] feat: extract bounded semantic C3 .NET facts (#63) --- CHANGELOG.md | 1 + .../Contracts/SemanticC3FactContracts.cs | 42 + src/Repo2C4.Core/Contracts/StableIds.cs | 41 + .../SemanticC3FactExtractionOptions.cs | 19 + .../Inspection/SemanticC3FactExtractor.cs | 1429 +++++++++++++++++ .../SemanticC3FactExtractorTests.cs | 315 ++++ 6 files changed, 1847 insertions(+) create mode 100644 src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs create mode 100644 src/Repo2C4.Core/Inspection/SemanticC3FactExtractionOptions.cs create mode 100644 src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ad038236..005de0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Bounded, cancellation-aware C# structural fact extraction for Semantic C3, covering symbols, DI/wiring, Minimal API/controllers, hosted services, DbContext, messaging role signals and conservative intra-project collaboration facts without build/code execution or source/literal propagation. - Additive, versioned Semantic C3 contract foundation with deterministic component taxonomy, stable source-symbol identity, reviewable internal/external relations, conceptual multi-container selection, and a reproducible v1.1.0 dogfooding baseline. ## [1.1.0] - 2026-10-02 diff --git a/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs b/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs new file mode 100644 index 00000000..58d2fc97 --- /dev/null +++ b/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs @@ -0,0 +1,42 @@ +using System.Collections.Immutable; + +namespace Repo2C4.Core.Contracts; + +/// Structural .NET fact kind used as input for later Semantic C3 classification. +public enum SemanticC3FactKind +{ + TypeDeclaration, + MethodDeclaration, + ConstructorDeclaration, + BaseType, + ImplementedInterface, + Attribute, + DependencyInjectionRegistration, + ConstructorInjection, + HttpBoundary, + ControllerAction, + HostedService, + PersistenceCandidate, + MessagingCandidate, + SymbolInvocation, +} + +/// +/// A bounded, verifiable structural fact. It never contains source bodies, arbitrary literals or absolute paths. +/// +public sealed record SemanticC3Fact( + string Id, + string ProjectPath, + string SourcePath, + int? Line, + SemanticC3SourceSymbolIdentity SourceSymbol, + SemanticC3FactKind Kind, + string Category, + string Description, + string? RelatedSymbolId); + +/// Deterministic result of local Semantic C3 structural fact extraction. +public sealed record SemanticC3FactSet( + string SchemaVersion, + ImmutableArray Facts, + ImmutableArray Diagnostics); diff --git a/src/Repo2C4.Core/Contracts/StableIds.cs b/src/Repo2C4.Core/Contracts/StableIds.cs index b907e530..a3e8202a 100644 --- a/src/Repo2C4.Core/Contracts/StableIds.cs +++ b/src/Repo2C4.Core/Contracts/StableIds.cs @@ -78,6 +78,47 @@ public static string ForSemanticC3Component( return Hash("cmp", containerId, category.ToString(), stableLogicalKey); } + public static string ForSemanticC3Fact( + string projectPath, + string sourcePath, + string sourceSymbolId, + SemanticC3FactKind kind, + string category, + string relatedSymbolId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceSymbolId); + ArgumentException.ThrowIfNullOrWhiteSpace(category); + ArgumentNullException.ThrowIfNull(relatedSymbolId); + + if (!ContractValidator.IsNormalizedRelativePath(projectPath)) + { + throw new ArgumentException( + "Semantic C3 fact project path must be repository-relative.", + nameof(projectPath)); + } + + if (!ContractValidator.IsNormalizedRelativePath(sourcePath)) + { + throw new ArgumentException( + "Semantic C3 fact source path must be repository-relative.", + nameof(sourcePath)); + } + + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + return Hash( + "fact", + projectPath, + sourcePath, + sourceSymbolId, + kind.ToString(), + category, + relatedSymbolId); + } + private static string Hash(string prefix, params string[] parts) { // Length-prefix fields so ["ab", "c"] cannot alias ["a", "bc"]. diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractionOptions.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractionOptions.cs new file mode 100644 index 00000000..68fe44ca --- /dev/null +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractionOptions.cs @@ -0,0 +1,19 @@ +namespace Repo2C4.Core.Inspection; + +/// Independent budgets for Semantic C3 C# structural analysis. +public sealed record SemanticC3FactExtractionOptions(RepositoryScanOptions ScanOptions) +{ + public int MaxCSharpFiles { get; init; } = 512; + + public long MaxTotalSourceBytes { get; init; } = 8_388_608; + + public int MaxSymbols { get; init; } = 4_096; + + public int MaxRelations { get; init; } = 8_192; + + public int MaxFacts { get; init; } = 20_000; + + public int MaxDiagnostics { get; init; } = 128; + + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); +} diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs new file mode 100644 index 00000000..ab8fc3d4 --- /dev/null +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -0,0 +1,1429 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using Repo2C4.Core.Contracts; + +namespace Repo2C4.Core.Inspection; + +/// +/// Extracts bounded structural C# facts for later Semantic C3 classification. +/// It performs no build, project evaluation, code execution, networking or runtime-flow inference. +/// +public static class SemanticC3FactExtractor +{ + private const int MaxIdentifierLength = 128; + private const int MaxAttributesPerSymbol = 32; + private const int MaxBaseTypesPerType = 32; + + private static readonly Regex NamespaceRegex = CreateRegex( + @"\bnamespace\s+(?[A-Za-z_][A-Za-z0-9_]{0,127}(?:\.[A-Za-z_][A-Za-z0-9_]{0,127})*)\s*(?:;|\{)"); + + private static readonly Regex TypeRegex = CreateRegex( + @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + + @"[ \t]*(?:(?:public|internal|private|protected|abstract|sealed|static|partial|readonly|ref)\s+)*" + + @"(?class|interface|struct|record(?:\s+class|\s+struct)?)\s+" + + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:\s*<[^>{;\r\n]+>)?" + + @"(?:\s*:\s*(?[^\{\r\n]+))?"); + + private static readonly Regex MethodRegex = CreateRegex( + @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + + @"[ \t]*(?:(?:public|private|protected|internal|static|virtual|override|abstract|async|sealed|new|partial|extern|unsafe|required)\s+)*" + + @"(?[A-Za-z_][A-Za-z0-9_?.<>\[\],]{0,255})\s+" + + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})\s*\((?[^()\r\n]*)\)\s*(?=>|\{|;)"); + + private static readonly Regex DiRegistrationRegex = CreateRegex( + @"\bAdd(?Scoped|Singleton|Transient)\s*<\s*" + + @"(?[A-Za-z_][A-Za-z0-9_.<>?,]{0,255})\s*" + + @"(?:,\s*(?[A-Za-z_][A-Za-z0-9_.<>?,]{0,255})\s*)?>\s*\("); + + private static readonly Regex HostedRegistrationRegex = CreateRegex( + @"\bAddHostedService\s*<\s*(?[A-Za-z_][A-Za-z0-9_.<>?,]{0,255})\s*>\s*\("); + + private static readonly Regex MinimalApiRegex = CreateRegex( + @"\b(?MapGet|MapPost|MapPut|MapDelete|MapPatch|MapMethods)\s*\("); + + private static readonly Regex StaticInvocationRegex = CreateRegex( + @"\b(?[A-Za-z_][A-Za-z0-9_]{0,127})\s*\.\s*" + + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})\s*\("); + + private static readonly Regex AttributeNameRegex = CreateRegex( + @"\b(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:Attribute)?\b"); + + public static SemanticC3FactSet Extract( + SemanticC3FactExtractionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + ValidateOptions(options); + cancellationToken.ThrowIfCancellationRequested(); + + Stopwatch stopwatch = Stopwatch.StartNew(); + DiagnosticAccumulator diagnostics = new(options.MaxDiagnostics); + RepositorySnapshot inventory = RepositoryScanner.Scan(options.ScanOptions, cancellationToken); + + if (CheckTime(stopwatch, options, diagnostics)) + { + return CreateResult([], diagnostics); + } + + string root = Path.GetFullPath(options.ScanOptions.RootPath); + string[] projects = + [ + .. inventory.Files + .Where(file => Path.GetExtension(file.RelativePath).Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + .Select(file => file.RelativePath) + .OrderBy(path => path, StringComparer.Ordinal), + ]; + + RepositoryFile[] sources = + [ + .. inventory.Files + .Where(file => Path.GetExtension(file.RelativePath).Equals(".cs", StringComparison.OrdinalIgnoreCase)) + .OrderBy(file => file.RelativePath, StringComparer.Ordinal), + ]; + + if (sources.Length > options.MaxCSharpFiles) + { + diagnostics.Add( + "semanticC3.fileLimit", + DiagnosticSeverity.Warning, + null, + "C# file budget reached; remaining source files were not analyzed."); + sources = [.. sources.Take(options.MaxCSharpFiles)]; + } + + List units = []; + long acceptedBytes = 0; + + foreach (RepositoryFile source in sources) + { + cancellationToken.ThrowIfCancellationRequested(); + if (CheckTime(stopwatch, options, diagnostics)) + { + break; + } + + if (source.SizeBytes > options.MaxTotalSourceBytes - acceptedBytes) + { + diagnostics.Add( + "semanticC3.totalBytesLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 source-byte budget reached; remaining source files were not analyzed."); + break; + } + + string? project = FindProject(source.RelativePath, projects, out bool ambiguous); + if (project is null) + { + diagnostics.Add( + "semanticC3.projectMissing", + DiagnosticSeverity.Info, + source.RelativePath, + "C# source has no inventoried containing project and was not semantically analyzed."); + continue; + } + + if (ambiguous) + { + diagnostics.Add( + "semanticC3.projectAmbiguous", + DiagnosticSeverity.Info, + source.RelativePath, + "Multiple containing projects have the same scope; the deterministic ordinal-first project was used."); + } + + (string? content, string? error) = RepositoryFileReader.Read( + root, + source, + options.ScanOptions, + cancellationToken); + + if (error is not null) + { + diagnostics.Add( + "semanticC3.readUnavailable", + DiagnosticSeverity.Warning, + source.RelativePath, + "Inventoried C# source could not be safely read and was not analyzed."); + continue; + } + + acceptedBytes += source.SizeBytes; + string masked = MaskSource(content!, out bool complete); + SourceUnit unit = ParseUnit(project, source.RelativePath, masked, cancellationToken); + units.Add(unit); + + if (!complete || unit.HasUnbalancedTypeBody) + { + diagnostics.Add( + "semanticC3.partialSyntax", + DiagnosticSeverity.Info, + source.RelativePath, + "C# source is incomplete or partially parseable; only bounded structural facts were retained."); + } + } + + FactAccumulator facts = new(options, diagnostics); + Dictionary> projectTypes = BuildProjectTypeIndex(units); + + foreach (SourceUnit unit in units.OrderBy(item => item.SourcePath, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (CheckTime(stopwatch, options, diagnostics) || facts.FactLimitReached) + { + break; + } + + AnalyzeUnit(unit, projectTypes, facts, diagnostics, cancellationToken); + } + + return CreateResult( + [.. facts.Items.OrderBy(item => item.Id, StringComparer.Ordinal)], + diagnostics); + } + + private static void AnalyzeUnit( + SourceUnit unit, + Dictionary> projectTypes, + FactAccumulator facts, + DiagnosticAccumulator diagnostics, + CancellationToken cancellationToken) + { + Dictionary knownTypes = projectTypes[unit.ProjectPath]; + + foreach (TypeDeclaration type in unit.Types) + { + cancellationToken.ThrowIfCancellationRequested(); + if (facts.FactLimitReached) + { + return; + } + + SemanticC3SourceSymbolIdentity typeSymbol = Identity(unit.ProjectPath, type.SymbolId); + facts.AddSymbol(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", + "Declares " + type.Kind + " symbol " + type.Name + ".", + null)); + + foreach (string attribute in type.Attributes.Take(MaxAttributesPerSymbol)) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.Attribute, + "semantic.symbol.attribute", + "Type declares attribute " + attribute + ".", + "A:" + attribute)); + } + + int baseIndex = 0; + foreach (string baseType in type.BaseTypes.Take(MaxBaseTypesPerType)) + { + string simple = SimpleTypeName(baseType); + bool interfaceType = knownTypes.TryGetValue(simple, out TypeDeclaration? local) + ? local.IsInterface + : LooksLikeInterface(simple); + + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + interfaceType ? SemanticC3FactKind.ImplementedInterface : SemanticC3FactKind.BaseType, + interfaceType ? "semantic.symbol.interface" : "semantic.symbol.baseType", + interfaceType + ? "Type implements interface " + simple + "." + : "Type derives from base type " + simple + ".", + (interfaceType ? "I:" : "B:") + simple + ":" + baseIndex)); + baseIndex++; + } + + if (IsController(type)) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.HttpBoundary, + "semantic.host.controller", + "Type is structurally identifiable as an ASP.NET Core controller.", + "controller")); + } + + if (type.BaseTypes.Any(item => SimpleTypeName(item) == "BackgroundService" || SimpleTypeName(item) == "IHostedService")) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.HostedService, + "semantic.host.backgroundService", + "Type declares a BackgroundService or IHostedService boundary.", + "hostedService")); + } + + if (type.BaseTypes.Any(item => SimpleTypeName(item) == "DbContext")) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.PersistenceCandidate, + "semantic.persistence.dbContext", + "Type derives from DbContext and is a persistence-boundary candidate.", + "dbContext")); + } + + if (IsMessagingAbstraction(type)) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", + "Type or implemented abstraction has a messaging publisher/consumer role signal.", + "messaging")); + } + + foreach (MethodDeclaration method in type.Methods) + { + cancellationToken.ThrowIfCancellationRequested(); + SemanticC3SourceSymbolIdentity methodSymbol = Identity(unit.ProjectPath, method.SymbolId); + facts.AddSymbol(Fact( + unit, + method.Line, + methodSymbol, + method.IsConstructor + ? SemanticC3FactKind.ConstructorDeclaration + : SemanticC3FactKind.MethodDeclaration, + method.IsConstructor ? "semantic.symbol.constructor" : "semantic.symbol.method", + method.IsConstructor + ? "Declares constructor for " + type.Name + "." + : "Declares method " + method.Name + ".", + null)); + + foreach (string attribute in method.Attributes.Take(MaxAttributesPerSymbol)) + { + facts.AddObservation(Fact( + unit, + method.Line, + methodSymbol, + SemanticC3FactKind.Attribute, + "semantic.symbol.attribute", + "Method declares attribute " + attribute + ".", + "A:" + attribute)); + } + + if (method.Attributes.Any(IsHttpAttribute)) + { + facts.AddObservation(Fact( + unit, + method.Line, + methodSymbol, + SemanticC3FactKind.ControllerAction, + "semantic.host.controllerAction", + "Method has an ASP.NET Core HTTP action attribute.", + "httpAction")); + } + + if (method.IsConstructor) + { + int parameterIndex = 0; + foreach (ParameterDeclaration parameter in method.Parameters) + { + facts.AddRelation(Fact( + unit, + method.Line, + methodSymbol, + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "Constructor depends on parameter type " + parameter.Type + ".", + "T:" + parameter.Type + ":" + parameterIndex)); + parameterIndex++; + } + } + + if (method.BodyStart >= 0 && method.BodyEnd > method.BodyStart) + { + foreach (Match invocation in StaticInvocationRegex.Matches( + unit.MaskedSource[method.BodyStart..method.BodyEnd])) + { + string typeName = invocation.Groups["type"].Value; + string methodName = invocation.Groups["method"].Value; + if (!knownTypes.TryGetValue(typeName, out TypeDeclaration? targetType)) + { + continue; + } + + string related = "M:" + targetType.QualifiedName + "." + methodName; + facts.AddRelation(Fact( + unit, + unit.LineAt(method.BodyStart + invocation.Index), + methodSymbol, + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "Method invokes a statically referenced symbol on " + typeName + ".", + related)); + } + } + } + } + + AnalyzeTopLevelSignals(unit, facts, cancellationToken); + } + + private static void AnalyzeTopLevelSignals( + SourceUnit unit, + FactAccumulator facts, + CancellationToken cancellationToken) + { + int routeOrdinal = 0; + foreach (Match match in MinimalApiRegex.Matches(unit.MaskedSource)) + { + cancellationToken.ThrowIfCancellationRequested(); + SemanticC3SourceSymbolIdentity source = unit.EnclosingSymbol(match.Index) + ?? Identity(unit.ProjectPath, "M:.Program.()"); + string api = match.Groups["api"].Value; + + facts.AddObservation(Fact( + unit, + unit.LineAt(match.Index), + source, + SemanticC3FactKind.HttpBoundary, + "semantic.host.minimalApi", + "Source invokes ASP.NET Core " + api + " route mapping.", + "route:" + api + ":" + routeOrdinal)); + routeOrdinal++; + } + + int registrationOrdinal = 0; + foreach (Match match in DiRegistrationRegex.Matches(unit.MaskedSource)) + { + cancellationToken.ThrowIfCancellationRequested(); + SemanticC3SourceSymbolIdentity source = unit.EnclosingSymbol(match.Index) + ?? Identity(unit.ProjectPath, "M:.Program.()"); + string lifetime = match.Groups["lifetime"].Value; + string service = NormalizeTypeToken(match.Groups["service"].Value); + string implementation = match.Groups["implementation"].Success + ? NormalizeTypeToken(match.Groups["implementation"].Value) + : service; + + facts.AddRelation(Fact( + unit, + unit.LineAt(match.Index), + source, + SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI registers " + service + " to " + implementation + " with " + lifetime + " lifetime.", + "DI:" + lifetime + ":" + service + "->" + implementation + ":" + registrationOrdinal)); + registrationOrdinal++; + } + + int hostedOrdinal = 0; + foreach (Match match in HostedRegistrationRegex.Matches(unit.MaskedSource)) + { + cancellationToken.ThrowIfCancellationRequested(); + SemanticC3SourceSymbolIdentity source = unit.EnclosingSymbol(match.Index) + ?? Identity(unit.ProjectPath, "M:.Program.()"); + string hostedType = NormalizeTypeToken(match.Groups["type"].Value); + + facts.AddRelation(Fact( + unit, + unit.LineAt(match.Index), + source, + SemanticC3FactKind.HostedService, + "semantic.wiring.hostedServiceRegistration", + "DI registers hosted service type " + hostedType + ".", + "HOST:" + hostedType + ":" + hostedOrdinal)); + hostedOrdinal++; + } + } + + private static SourceUnit ParseUnit( + string projectPath, + string sourcePath, + string masked, + CancellationToken cancellationToken) + { + int[] lineStarts = BuildLineStarts(masked); + int[] braceDepth = BuildBraceDepth(masked); + Match[] namespaces = [.. NamespaceRegex.Matches(masked).Cast()]; + List types = []; + bool unbalanced = false; + + foreach (Match match in TypeRegex.Matches(masked)) + { + cancellationToken.ThrowIfCancellationRequested(); + string kind = match.Groups["kind"].Value.Replace(" ", string.Empty, StringComparison.Ordinal); + string name = match.Groups["name"].Value; + string ns = NamespaceAt(namespaces, match.Index); + string qualified = string.IsNullOrEmpty(ns) ? name : ns + "." + name; + string[] bases = SplitTypeList(match.Groups["bases"].Value); + string[] attributes = ExtractAttributes(match.Groups["attrs"].Value); + + int openBrace = masked.IndexOf('{', match.Index + match.Length); + int bodyStart = -1; + int bodyEnd = -1; + int bodyDepth = -1; + + if (openBrace >= 0) + { + int closeBrace = FindMatchingBrace(masked, openBrace); + bodyStart = openBrace + 1; + bodyEnd = closeBrace >= 0 ? closeBrace : masked.Length; + bodyDepth = braceDepth[bodyStart]; + unbalanced |= closeBrace < 0; + } + + TypeDeclaration type = new( + name, + qualified, + kind, + "interface".Equals(kind, StringComparison.Ordinal), + LineAt(lineStarts, match.Index), + match.Index, + bodyStart, + bodyEnd, + bodyDepth, + bases, + attributes, + []); + + if (bodyStart >= 0) + { + type = type with + { + Methods = ParseMethods(masked, braceDepth, lineStarts, type), + }; + } + + types.Add(type); + } + + return new SourceUnit( + projectPath, + sourcePath, + masked, + lineStarts, + [.. types.OrderBy(type => type.StartIndex)], + unbalanced); + } + + private static ImmutableArray ParseMethods( + string masked, + int[] braceDepth, + int[] lineStarts, + TypeDeclaration type) + { + if (type.BodyStart < 0 || type.BodyEnd <= type.BodyStart) + { + return []; + } + + string body = masked[type.BodyStart..type.BodyEnd]; + List methods = []; + + foreach (Match match in MethodRegex.Matches(body)) + { + int absolute = type.BodyStart + match.Index; + if (braceDepth[absolute] != type.BodyDepth) + { + continue; + } + + string name = match.Groups["name"].Value; + if (IsControlKeyword(name)) + { + continue; + } + + ParameterDeclaration[] parameters = ParseParameters(match.Groups["params"].Value); + string symbolId = MethodSymbolId(type, name, parameters, isConstructor: false); + (int start, int end) = MethodBody(masked, absolute, match); + + methods.Add(new MethodDeclaration( + name, + symbolId, + false, + LineAt(lineStarts, absolute), + ExtractAttributes(match.Groups["attrs"].Value), + parameters, + start, + end, + absolute)); + } + + Regex constructorRegex = CreateRegex( + @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + + @"[ \t]*(?:(?:public|private|protected|internal|static|extern|unsafe)\s+)*" + + Regex.Escape(type.Name) + + @"\s*\((?[^()\r\n]*)\)\s*(?=>|\{|;)"); + + foreach (Match match in constructorRegex.Matches(body)) + { + int absolute = type.BodyStart + match.Index; + if (braceDepth[absolute] != type.BodyDepth) + { + continue; + } + + ParameterDeclaration[] parameters = ParseParameters(match.Groups["params"].Value); + string symbolId = MethodSymbolId(type, "#ctor", parameters, isConstructor: true); + (int start, int end) = MethodBody(masked, absolute, match); + + methods.Add(new MethodDeclaration( + type.Name, + symbolId, + true, + LineAt(lineStarts, absolute), + ExtractAttributes(match.Groups["attrs"].Value), + parameters, + start, + end, + absolute)); + } + + return + [ + .. methods + .GroupBy(method => method.SymbolId, StringComparer.Ordinal) + .Select(group => group.OrderBy(method => method.StartIndex).First()) + .OrderBy(method => method.StartIndex), + ]; + } + + private static (int Start, int End) MethodBody(string masked, int absoluteStart, Match relativeMatch) + { + Group tail = relativeMatch.Groups["tail"]; + int tailAbsolute = absoluteStart + tail.Index - relativeMatch.Index; + + if (tail.Value != "{") + { + return (-1, -1); + } + + int close = FindMatchingBrace(masked, tailAbsolute); + return close >= 0 ? (tailAbsolute + 1, close) : (tailAbsolute + 1, masked.Length); + } + + private static Dictionary> BuildProjectTypeIndex( + IEnumerable units) + { + Dictionary> result = new(StringComparer.Ordinal); + + foreach (SourceUnit unit in units) + { + if (!result.TryGetValue(unit.ProjectPath, out Dictionary? types)) + { + types = new Dictionary(StringComparer.Ordinal); + result.Add(unit.ProjectPath, types); + } + + foreach (TypeDeclaration type in unit.Types) + { + types.TryAdd(type.Name, type); + types.TryAdd(type.QualifiedName, type); + } + } + + return result; + } + + private static string? FindProject(string sourcePath, string[] projects, out bool ambiguous) + { + ambiguous = false; + string sourceDirectory = DirectoryPart(sourcePath); + + (string Path, int Depth)[] candidates = + [ + .. projects + .Select(project => (Path: project, Depth: DirectoryDepth(DirectoryPart(project)))) + .Where(candidate => IsWithin(sourceDirectory, DirectoryPart(candidate.Path))) + .OrderByDescending(candidate => candidate.Depth) + .ThenBy(candidate => candidate.Path, StringComparer.Ordinal), + ]; + + if (candidates.Length == 0) + { + return null; + } + + int bestDepth = candidates[0].Depth; + ambiguous = candidates.Count(candidate => candidate.Depth == bestDepth) > 1; + return candidates[0].Path; + } + + private static bool IsWithin(string sourceDirectory, string projectDirectory) => + projectDirectory.Length == 0 || + sourceDirectory.Equals(projectDirectory, StringComparison.Ordinal) || + sourceDirectory.StartsWith(projectDirectory + "/", StringComparison.Ordinal); + + private static string DirectoryPart(string path) + { + int separator = path.LastIndexOf('/'); + return separator < 0 ? string.Empty : path[..separator]; + } + + private static int DirectoryDepth(string path) => + path.Length == 0 ? 0 : path.Count(character => character == '/') + 1; + + private static string NamespaceAt(Match[] namespaces, int position) + { + string current = string.Empty; + foreach (Match item in namespaces) + { + if (item.Index > position) + { + break; + } + + current = item.Groups["name"].Value; + } + + return current; + } + + private static ParameterDeclaration[] ParseParameters(string value) + { + List result = []; + + foreach (string item in SplitCommaSeparated(value)) + { + string parameter = RemoveAttributeBlocks(item).Trim(); + int equals = parameter.IndexOf('='); + if (equals >= 0) + { + parameter = parameter[..equals].Trim(); + } + + string[] tokens = parameter + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token is not "ref" and not "out" and not "in" and not "params" + and not "this" and not "scoped") + .ToArray(); + + if (tokens.Length < 2) + { + continue; + } + + string type = NormalizeTypeToken(string.Join(" ", tokens[..^1])); + string name = tokens[^1].Trim(); + if (IsIdentifier(name) && type.Length > 0) + { + result.Add(new ParameterDeclaration(type, name)); + } + } + + return [.. result]; + } + + private static string[] SplitTypeList(string value) => + [.. SplitCommaSeparated(value) + .Select(item => NormalizeTypeToken(item)) + .Where(item => item.Length > 0) + .Take(MaxBaseTypesPerType)]; + + private static IEnumerable SplitCommaSeparated(string value) + { + int start = 0; + int angle = 0; + int square = 0; + + for (int i = 0; i < value.Length; i++) + { + switch (value[i]) + { + case '<': + angle++; + break; + case '>': + angle = Math.Max(0, angle - 1); + break; + case '[': + square++; + break; + case ']': + square = Math.Max(0, square - 1); + break; + case ',' when angle == 0 && square == 0: + yield return value[start..i]; + start = i + 1; + break; + } + } + + if (start <= value.Length) + { + yield return value[start..]; + } + } + + private static string[] ExtractAttributes(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + List attributes = []; + foreach (Match block in Regex.Matches( + value, + @"\[(?[^\]\r\n]+)\]", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking, + TimeSpan.FromMilliseconds(100))) + { + foreach (string item in SplitCommaSeparated(block.Groups["body"].Value)) + { + Match name = AttributeNameRegex.Match(item); + if (!name.Success) + { + continue; + } + + string attribute = name.Groups["name"].Value; + if (attribute.EndsWith("Attribute", StringComparison.Ordinal)) + { + attribute = attribute[..^"Attribute".Length]; + } + + if (!attributes.Contains(attribute, StringComparer.Ordinal)) + { + attributes.Add(attribute); + } + + if (attributes.Count >= MaxAttributesPerSymbol) + { + return [.. attributes]; + } + } + } + + return [.. attributes]; + } + + private static string RemoveAttributeBlocks(string value) => + Regex.Replace( + value, + @"\[[^\]]*\]", + string.Empty, + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking, + TimeSpan.FromMilliseconds(100)); + + private static string MethodSymbolId( + TypeDeclaration type, + string methodName, + ParameterDeclaration[] parameters, + bool isConstructor) + { + string parametersPart = string.Join(",", parameters.Select(parameter => parameter.Type)); + string name = isConstructor ? "#ctor" : methodName; + return "M:" + type.QualifiedName + "." + name + "(" + parametersPart + ")"; + } + + private static SemanticC3SourceSymbolIdentity Identity(string projectPath, string symbolId) => + new( + StableIds.ForSemanticC3SourceSymbol(projectPath, symbolId), + projectPath, + symbolId); + + private static SemanticC3Fact Fact( + SourceUnit unit, + int? line, + SemanticC3SourceSymbolIdentity source, + SemanticC3FactKind kind, + string category, + string description, + string? relatedSymbolId) + { + string id = StableIds.ForSemanticC3Fact( + unit.ProjectPath, + unit.SourcePath, + source.Id, + kind, + category, + relatedSymbolId ?? string.Empty); + + return new SemanticC3Fact( + id, + unit.ProjectPath, + unit.SourcePath, + line, + source, + kind, + category, + description, + relatedSymbolId); + } + + private static bool IsController(TypeDeclaration type) => + type.Attributes.Contains("ApiController", StringComparer.Ordinal) || + type.BaseTypes.Any(item => + SimpleTypeName(item) is "Controller" or "ControllerBase"); + + private static bool IsMessagingAbstraction(TypeDeclaration type) + { + IEnumerable candidates = type.BaseTypes + .Select(SimpleTypeName) + .Append(type.Name); + + return candidates.Any(item => + item.Contains("Publisher", StringComparison.OrdinalIgnoreCase) || + item.Contains("Producer", StringComparison.OrdinalIgnoreCase) || + item.Contains("Consumer", StringComparison.OrdinalIgnoreCase) || + item.Contains("Subscriber", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsHttpAttribute(string attribute) => + attribute.StartsWith("HttpGet", StringComparison.Ordinal) || + attribute.StartsWith("HttpPost", StringComparison.Ordinal) || + attribute.StartsWith("HttpPut", StringComparison.Ordinal) || + attribute.StartsWith("HttpDelete", StringComparison.Ordinal) || + attribute.StartsWith("HttpPatch", StringComparison.Ordinal) || + attribute.StartsWith("HttpHead", StringComparison.Ordinal) || + attribute.StartsWith("HttpOptions", StringComparison.Ordinal); + + private static bool LooksLikeInterface(string typeName) => + typeName.Length >= 2 && + typeName[0] == 'I' && + char.IsUpper(typeName[1]); + + private static string SimpleTypeName(string type) + { + string normalized = NormalizeTypeToken(type); + int generic = normalized.IndexOf('<'); + if (generic >= 0) + { + normalized = normalized[..generic]; + } + + int dot = normalized.LastIndexOf('.'); + return dot >= 0 ? normalized[(dot + 1)..] : normalized; + } + + private static string NormalizeTypeToken(string value) + { + StringBuilder builder = new(); + foreach (char character in value.Trim()) + { + if (char.IsWhiteSpace(character)) + { + continue; + } + + if (char.IsLetterOrDigit(character) || + character is '_' or '.' or '<' or '>' or ',' or '?' or '[' or ']') + { + builder.Append(character); + } + + if (builder.Length >= 256) + { + break; + } + } + + return builder.ToString().Replace("global::", string.Empty, StringComparison.Ordinal); + } + + private static bool IsIdentifier(string value) => + value.Length is > 0 and <= MaxIdentifierLength && + (char.IsLetter(value[0]) || value[0] == '_') && + value.Skip(1).All(character => char.IsLetterOrDigit(character) || character == '_'); + + private static bool IsControlKeyword(string value) => + value is "if" or "for" or "foreach" or "while" or "switch" or "catch" + or "using" or "lock" or "return" or "nameof" or "typeof"; + + private static int[] BuildLineStarts(string source) + { + List starts = [0]; + for (int i = 0; i < source.Length; i++) + { + if (source[i] == '\n') + { + starts.Add(i + 1); + } + } + + return [.. starts]; + } + + private static int LineAt(int[] lineStarts, int index) + { + int found = Array.BinarySearch(lineStarts, index); + int position = found >= 0 ? found : ~found - 1; + return Math.Max(0, position) + 1; + } + + private static int[] BuildBraceDepth(string source) + { + int[] depth = new int[source.Length + 1]; + for (int i = 0; i < source.Length; i++) + { + int next = depth[i]; + if (source[i] == '{') + { + next++; + } + else if (source[i] == '}') + { + next = Math.Max(0, next - 1); + } + + depth[i + 1] = next; + } + + return depth; + } + + private static int FindMatchingBrace(string source, int openBrace) + { + int depth = 0; + for (int i = openBrace; i < source.Length; i++) + { + if (source[i] == '{') + { + depth++; + } + else if (source[i] == '}' && --depth == 0) + { + return i; + } + } + + return -1; + } + + private static string MaskSource(string source, out bool complete) + { + char[] chars = source.ToCharArray(); + complete = true; + int i = 0; + + while (i < chars.Length) + { + if (chars[i] == '/' && i + 1 < chars.Length && chars[i + 1] == '/') + { + Mask(chars, i, i + 2); + i += 2; + while (i < chars.Length && chars[i] != '\n') + { + chars[i++] = ' '; + } + + continue; + } + + if (chars[i] == '/' && i + 1 < chars.Length && chars[i + 1] == '*') + { + Mask(chars, i, i + 2); + i += 2; + bool closed = false; + while (i < chars.Length) + { + if (chars[i] == '*' && i + 1 < chars.Length && chars[i + 1] == '/') + { + Mask(chars, i, i + 2); + i += 2; + closed = true; + break; + } + + if (chars[i] != '\n' && chars[i] != '\r') + { + chars[i] = ' '; + } + + i++; + } + + complete &= closed; + continue; + } + + if (chars[i] == ''') + { + i = MaskQuoted(chars, i, ''', verbatim: false, out bool closed); + complete &= closed; + continue; + } + + int quoteRun = QuoteRun(chars, i); + if (quoteRun >= 3) + { + i = MaskRawString(chars, i, quoteRun, out bool closed); + complete &= closed; + continue; + } + + bool verbatim = chars[i] == '"' && i > 0 && chars[i - 1] == '@'; + if (chars[i] == '"') + { + i = MaskQuoted(chars, i, '"', verbatim, out bool closed); + complete &= closed; + continue; + } + + i++; + } + + return new string(chars); + } + + private static int MaskQuoted(char[] chars, int start, char delimiter, bool verbatim, out bool closed) + { + closed = false; + chars[start] = ' '; + int i = start + 1; + + while (i < chars.Length) + { + if (chars[i] == '\n' || chars[i] == '\r') + { + if (delimiter == ''') + { + break; + } + + i++; + continue; + } + + if (chars[i] == delimiter) + { + if (verbatim && i + 1 < chars.Length && chars[i + 1] == delimiter) + { + chars[i] = ' '; + chars[i + 1] = ' '; + i += 2; + continue; + } + + chars[i] = ' '; + i++; + closed = true; + break; + } + + if (!verbatim && chars[i] == '\\' && i + 1 < chars.Length) + { + chars[i] = ' '; + if (chars[i + 1] != '\n' && chars[i + 1] != '\r') + { + chars[i + 1] = ' '; + } + + i += 2; + continue; + } + + chars[i] = ' '; + i++; + } + + return i; + } + + private static int MaskRawString(char[] chars, int start, int quoteRun, out bool closed) + { + closed = false; + Mask(chars, start, start + quoteRun); + int i = start + quoteRun; + + while (i < chars.Length) + { + int run = QuoteRun(chars, i); + if (run >= quoteRun) + { + Mask(chars, i, i + quoteRun); + i += quoteRun; + closed = true; + break; + } + + if (chars[i] != '\n' && chars[i] != '\r') + { + chars[i] = ' '; + } + + i++; + } + + return i; + } + + private static int QuoteRun(char[] chars, int start) + { + if (start >= chars.Length || chars[start] != '"') + { + return 0; + } + + int i = start; + while (i < chars.Length && chars[i] == '"') + { + i++; + } + + return i - start; + } + + private static void Mask(char[] chars, int start, int end) + { + for (int i = start; i < end && i < chars.Length; i++) + { + if (chars[i] != '\n' && chars[i] != '\r') + { + chars[i] = ' '; + } + } + } + + private static bool CheckTime( + Stopwatch stopwatch, + SemanticC3FactExtractionOptions options, + DiagnosticAccumulator diagnostics) + { + if (stopwatch.Elapsed <= options.Timeout) + { + return false; + } + + diagnostics.Add( + "semanticC3.timeLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 analysis time budget reached; remaining source was not analyzed."); + return true; + } + + private static SemanticC3FactSet CreateResult( + ImmutableArray facts, + DiagnosticAccumulator diagnostics) => + new( + SemanticC3ContractSchema.Version, + facts, + diagnostics.Items); + + private static void ValidateOptions(SemanticC3FactExtractionOptions options) + { + ArgumentNullException.ThrowIfNull(options.ScanOptions); + + if (options.MaxCSharpFiles <= 0 || + options.MaxTotalSourceBytes <= 0 || + options.MaxSymbols <= 0 || + options.MaxRelations <= 0 || + options.MaxFacts <= 0 || + options.MaxDiagnostics < 2 || + options.Timeout <= TimeSpan.Zero || + options.Timeout > TimeSpan.FromMinutes(1)) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "Semantic C3 budgets must be positive, diagnostics must allow at least two entries, and timeout must not exceed one minute."); + } + } + + private static Regex CreateRegex(string pattern) => + new( + pattern, + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(100)); + + private sealed record ParameterDeclaration(string Type, string Name); + + private sealed record MethodDeclaration( + string Name, + string SymbolId, + bool IsConstructor, + int Line, + string[] Attributes, + ParameterDeclaration[] Parameters, + int BodyStart, + int BodyEnd, + int StartIndex); + + private sealed record TypeDeclaration( + string Name, + string QualifiedName, + string Kind, + bool IsInterface, + int Line, + int StartIndex, + int BodyStart, + int BodyEnd, + int BodyDepth, + string[] BaseTypes, + string[] Attributes, + ImmutableArray Methods) + { + public string SymbolId => "T:" + QualifiedName; + } + + private sealed record SourceUnit( + string ProjectPath, + string SourcePath, + string MaskedSource, + int[] LineStarts, + ImmutableArray Types, + bool HasUnbalancedTypeBody) + { + public int LineAt(int index) => SemanticC3FactExtractor.LineAt(LineStarts, index); + + public SemanticC3SourceSymbolIdentity? EnclosingSymbol(int index) + { + MethodDeclaration? method = Types + .SelectMany(type => type.Methods) + .Where(candidate => + candidate.BodyStart >= 0 && + index >= candidate.BodyStart && + index <= candidate.BodyEnd) + .OrderByDescending(candidate => candidate.BodyStart) + .FirstOrDefault(); + + if (method is not null) + { + return Identity(ProjectPath, method.SymbolId); + } + + TypeDeclaration? type = Types + .Where(candidate => + candidate.BodyStart >= 0 && + index >= candidate.BodyStart && + index <= candidate.BodyEnd) + .OrderByDescending(candidate => candidate.BodyStart) + .FirstOrDefault(); + + return type is null ? null : Identity(ProjectPath, type.SymbolId); + } + } + + private sealed class FactAccumulator( + SemanticC3FactExtractionOptions options, + DiagnosticAccumulator diagnostics) + { + private readonly HashSet ids = new(StringComparer.Ordinal); + private int symbolCount; + private int relationCount; + + public List Items { get; } = []; + + public bool FactLimitReached + { + get; + private set; + } + + public void AddSymbol(SemanticC3Fact fact) + { + if (symbolCount >= options.MaxSymbols) + { + diagnostics.Add( + "semanticC3.symbolLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 symbol budget reached; additional symbols were omitted."); + return; + } + + if (TryAdd(fact)) + { + symbolCount++; + } + } + + public void AddRelation(SemanticC3Fact fact) + { + if (relationCount >= options.MaxRelations) + { + diagnostics.Add( + "semanticC3.relationLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 relation budget reached; additional wiring/collaboration facts were omitted."); + return; + } + + if (TryAdd(fact)) + { + relationCount++; + } + } + + public void AddObservation(SemanticC3Fact fact) => TryAdd(fact); + + private bool TryAdd(SemanticC3Fact fact) + { + if (FactLimitReached || !ids.Add(fact.Id)) + { + return false; + } + + if (Items.Count >= options.MaxFacts) + { + FactLimitReached = true; + diagnostics.Add( + "semanticC3.factLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 fact budget reached; additional facts were omitted."); + return false; + } + + Items.Add(fact); + return true; + } + } + + private sealed class DiagnosticAccumulator(int maxDiagnostics) + { + private readonly List items = []; + private readonly HashSet keys = new(StringComparer.Ordinal); + private bool limitRecorded; + + public ImmutableArray Items => + [ + .. items + .OrderBy(item => item.Code, StringComparer.Ordinal) + .ThenBy(item => item.RelativePath, StringComparer.Ordinal) + .ThenBy(item => item.Message, StringComparer.Ordinal), + ]; + + public void Add( + string code, + DiagnosticSeverity severity, + string? relativePath, + string message) + { + string key = code + "\n" + relativePath; + if (!keys.Add(key)) + { + return; + } + + if (items.Count < maxDiagnostics - 1) + { + items.Add(new RepositoryDiagnostic(code, severity, relativePath, message)); + return; + } + + if (!limitRecorded) + { + limitRecorded = true; + items.Add(new RepositoryDiagnostic( + "semanticC3.diagnosticLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 diagnostic budget reached; additional diagnostics were omitted.")); + } + } + } +} diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs new file mode 100644 index 00000000..ae18f272 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -0,0 +1,315 @@ +using Repo2C4.Core.Contracts; +using Repo2C4.Core.Inspection; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3FactExtractorTests +{ + [Fact] + public void ExtractsApiControllerWorkerDiPersistenceMessagingAndCollaborationFacts() + { + using Fixture fixture = new(); + fixture.Add("src/App/App.csproj", ""); + fixture.Add("src/App/Program.cs", """ + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + var app = builder.Build(); + app.MapPost("/orders", () => Results.Ok()); + """); + fixture.Add("src/App/OrdersController.cs", """ + namespace Sample.App; + + [ApiController] + public sealed class OrdersController : ControllerBase + { + private readonly IOrderService _service; + + public OrdersController(IOrderService service) + { + _service = service; + } + + [HttpGet] + public IActionResult Get() => Ok(); + } + + public interface IOrderService + { + void Handle(); + } + + public sealed class OrderService : IOrderService + { + public void Handle() + { + Helper.Process(); + } + } + + public static class Helper + { + public static void Process() { } + } + """); + fixture.Add("src/App/Worker.cs", """ + namespace Sample.App; + + public sealed class PollerWorker : BackgroundService + { + protected override Task ExecuteAsync(CancellationToken stoppingToken) => Task.CompletedTask; + } + """); + fixture.Add("src/App/Data.cs", """ + namespace Sample.App; + + public sealed class OrdersDbContext : DbContext { } + + public interface IEventPublisher { } + public sealed class RabbitPublisher : IEventPublisher { } + + public interface IOrderConsumer { } + """); + + SemanticC3FactSet result = Extract(fixture); + + Assert.Contains(result.Facts, fact => fact.Category == "semantic.host.minimalApi"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.host.controller"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.host.controllerAction"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.wiring.diRegistration" + && fact.Description.Contains("IOrderService", StringComparison.Ordinal) + && fact.Description.Contains("OrderService", StringComparison.Ordinal)); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.wiring.constructorInjection" + && fact.Description.Contains("IOrderService", StringComparison.Ordinal)); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.host.backgroundService"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.wiring.hostedServiceRegistration"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.persistence.dbContext"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.messaging.abstraction"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.collaboration.staticInvocation" + && fact.RelatedSymbolId!.Contains("Helper.Process", StringComparison.Ordinal)); + + Assert.All(result.Facts, fact => + { + Assert.Equal("src/App/App.csproj", fact.ProjectPath); + Assert.StartsWith("src/App/", fact.SourcePath, StringComparison.Ordinal); + Assert.True(fact.Line is null or >= 1); + Assert.StartsWith("sym_", fact.SourceSymbol.Id, StringComparison.Ordinal); + }); + } + + [Fact] + public void OutputIsDeterministicAndStableAcrossRepeatedExtraction() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("Service.cs", """ + namespace Demo; + public interface IService { } + public sealed class Service : IService + { + public Service(IService dependency) { } + } + """); + + SemanticC3FactSet first = Extract(fixture); + SemanticC3FactSet second = Extract(fixture); + + Assert.Equal(first.Facts.Select(item => item.Id), second.Facts.Select(item => item.Id)); + Assert.Equal(first.Diagnostics, second.Diagnostics); + Assert.Equal( + first.Facts.Select(item => item.SourceSymbol.Id), + second.Facts.Select(item => item.SourceSymbol.Id)); + } + + [Fact] + public void SymbolAndRelationBudgetsProduceControlledDiagnostics() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add( + "Many.cs", + string.Join( + Environment.NewLine, + Enumerable.Range(0, 200).Select(index => + "public sealed class Type" + index + " { public Type" + index + "(IDependency dependency) { } }"))); + + SemanticC3FactSet result = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) + { + MaxSymbols = 10, + MaxRelations = 5, + MaxFacts = 100, + }); + + Assert.Contains(result.Diagnostics, item => item.Code == "semanticC3.symbolLimit"); + Assert.Contains(result.Diagnostics, item => item.Code == "semanticC3.relationLimit"); + Assert.True(result.Facts.Count(item => + item.Kind is SemanticC3FactKind.TypeDeclaration + or SemanticC3FactKind.MethodDeclaration + or SemanticC3FactKind.ConstructorDeclaration) <= 10); + Assert.True(result.Facts.Count(item => + item.Kind is SemanticC3FactKind.ConstructorInjection + or SemanticC3FactKind.DependencyInjectionRegistration + or SemanticC3FactKind.SymbolInvocation) <= 5); + } + + [Fact] + public void FileByteTimeAndDiagnosticBudgetsAreBounded() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("A.cs", "public sealed class A { }"); + fixture.Add("B.cs", "public sealed class B { }"); + + SemanticC3FactSet fileLimited = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) + { + MaxCSharpFiles = 1, + }); + Assert.Contains(fileLimited.Diagnostics, item => item.Code == "semanticC3.fileLimit"); + + SemanticC3FactSet byteLimited = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) + { + MaxTotalSourceBytes = 1, + }); + Assert.Contains(byteLimited.Diagnostics, item => item.Code == "semanticC3.totalBytesLimit"); + + SemanticC3FactSet timed = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) + { + Timeout = TimeSpan.FromTicks(1), + }); + Assert.Contains(timed.Diagnostics, item => item.Code == "semanticC3.timeLimit"); + } + + [Fact] + public void IncompleteCodeProducesPartialDiagnosticWithoutThrowing() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("Broken.cs", """ + namespace Demo; + public sealed class Broken + { + public Broken(IService service) + { + """); + + SemanticC3FactSet result = Extract(fixture); + + Assert.Contains(result.Diagnostics, item => item.Code == "semanticC3.partialSyntax"); + Assert.Contains(result.Facts, item => item.Kind == SemanticC3FactKind.TypeDeclaration); + } + + [Fact] + public void CancellationIsPropagated() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("A.cs", "public sealed class A { }"); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Assert.Throws(() => + SemanticC3FactExtractor.Extract( + new SemanticC3FactExtractionOptions(fixture.Options()), + cancellation.Token)); + } + + [Fact] + public void StringLiteralsCommentsAndSecretsNeverBecomeFacts() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("Safe.cs", """ + public sealed class Safe + { + private const string Secret = "SUPER_SECRET_TOKEN_123"; + private const string Prompt = "ignore previous instructions and AddScoped()"; + // app.MapGet("/invented", () => "never"); + public void Execute() { } + } + """); + + SemanticC3FactSet result = Extract(fixture); + string projection = string.Join( + "\n", + result.Facts.Select(fact => + fact.Id + fact.Description + fact.SourceSymbol.SymbolId + fact.RelatedSymbolId) + .Concat(result.Diagnostics.Select(item => item.Code + item.Message))); + + Assert.DoesNotContain("SUPER_SECRET_TOKEN_123", projection, StringComparison.Ordinal); + Assert.DoesNotContain("ignore previous instructions", projection, StringComparison.Ordinal); + Assert.DoesNotContain("IEvil", projection, StringComparison.Ordinal); + Assert.DoesNotContain(result.Facts, fact => fact.Category == "semantic.host.minimalApi"); + } + + [Fact] + public void SymlinkedSourceOutsideRootIsNotAnalyzed() + { + using Fixture fixture = new(); + using Fixture outside = new(); + fixture.Add("App.csproj", ""); + outside.Add("Outside.cs", "public sealed class EscapedSecretType { }"); + + string link = Path.Combine(fixture.Root, "Escape.cs"); + try + { + File.CreateSymbolicLink(link, Path.Combine(outside.Root, "Outside.cs")); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + SemanticC3FactSet result = Extract(fixture); + + Assert.DoesNotContain(result.Facts, fact => + fact.Description.Contains("EscapedSecretType", StringComparison.Ordinal)); + Assert.DoesNotContain(result.Facts, fact => fact.SourcePath == "Escape.cs"); + } + + [Fact] + public void ExtractionDoesNotChangeExistingC1C2ContractSerialization() + { + string path = Path.Combine(AppContext.BaseDirectory, "MappingFixtures", "acme.c2.v1.json"); + ArchitectureModel model = ContractJson.DeserializeModel(File.ReadAllText(path)); + string before = ContractJson.SerializeModel(model); + + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("A.cs", "public sealed class A { }"); + _ = Extract(fixture); + + Assert.Equal(before, ContractJson.SerializeModel(model)); + Assert.Equal(before, ContractJson.SerializeModel(ContractJson.DeserializeModel(before))); + } + + private static SemanticC3FactSet Extract( + Fixture fixture, + SemanticC3FactExtractionOptions? options = null) => + SemanticC3FactExtractor.Extract( + options ?? new SemanticC3FactExtractionOptions(fixture.Options()), + TestContext.Current.CancellationToken); + + private sealed class Fixture : IDisposable + { + public Fixture() + { + Root = Path.Combine(Path.GetTempPath(), "repo2c4_semantic_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Root); + } + + public string Root { get; } + + public RepositoryScanOptions Options() => new(Root, "semantic_fixture"); + + public string Add(string relative, string content) + { + string path = Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} From b9fb8d51ae82d421744b4e997c8251273bbed11f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:28:35 -0300 Subject: [PATCH 03/74] fix: align semantic C3 extractor with formatting gates (#63) --- .../Inspection/SemanticC3FactExtractor.cs | 6 +++--- .../SemanticC3FactExtractorTests.cs | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index ab8fc3d4..d50ffa24 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -1049,9 +1049,9 @@ private static string MaskSource(string source, out bool complete) continue; } - if (chars[i] == ''') + if (chars[i] == (char)39) { - i = MaskQuoted(chars, i, ''', verbatim: false, out bool closed); + i = MaskQuoted(chars, i, (char)39, verbatim: false, out bool closed); complete &= closed; continue; } @@ -1088,7 +1088,7 @@ private static int MaskQuoted(char[] chars, int start, char delimiter, bool verb { if (chars[i] == '\n' || chars[i] == '\r') { - if (delimiter == ''') + if (delimiter == (char)39) { break; } diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index ae18f272..6e496f0f 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -131,8 +131,14 @@ public void SymbolAndRelationBudgetsProduceControlledDiagnostics() "Many.cs", string.Join( Environment.NewLine, - Enumerable.Range(0, 200).Select(index => - "public sealed class Type" + index + " { public Type" + index + "(IDependency dependency) { } }"))); + Enumerable.Range(0, 200).Select(index => $""" + public sealed class Type{{index}} + { + public Type{{index}}(IDependency dependency) + { + } + } + """))); SemanticC3FactSet result = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) { @@ -298,7 +304,10 @@ public Fixture() Directory.CreateDirectory(Root); } - public string Root { get; } + public string Root + { + get; + } public RepositoryScanOptions Options() => new(Root, "semantic_fixture"); From 0352068bfe38061e5efd0693688dfa6a50c3fdb3 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:31:47 -0300 Subject: [PATCH 04/74] fix: make semantic C3 budget fixture compile (#63) --- .../SemanticC3FactExtractorTests.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index 6e496f0f..ec71c07e 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -131,14 +131,13 @@ public void SymbolAndRelationBudgetsProduceControlledDiagnostics() "Many.cs", string.Join( Environment.NewLine, - Enumerable.Range(0, 200).Select(index => $""" - public sealed class Type{{index}} - { - public Type{{index}}(IDependency dependency) - { - } - } - """))); + Enumerable.Range(0, 200).Select(index => + "public sealed class Type" + index + Environment.NewLine + + "{" + Environment.NewLine + + " public Type" + index + "(IDependency dependency)" + Environment.NewLine + + " {" + Environment.NewLine + + " }" + Environment.NewLine + + "}"))) SemanticC3FactSet result = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) { From 54b3686c7a8c694e2e0e5ee4cfa5f18a63618dd5 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:33:37 -0300 Subject: [PATCH 05/74] fix: close semantic C3 budget fixture statement (#63) --- tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index ec71c07e..bb5de46b 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -137,7 +137,7 @@ public void SymbolAndRelationBudgetsProduceControlledDiagnostics() " public Type" + index + "(IDependency dependency)" + Environment.NewLine + " {" + Environment.NewLine + " }" + Environment.NewLine + - "}"))) + "}"))); SemanticC3FactSet result = Extract(fixture, new SemanticC3FactExtractionOptions(fixture.Options()) { From e0b9c8591ce8365dfaa30d966d0189aa4e8b15da Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:37:27 -0300 Subject: [PATCH 06/74] fix: reuse bounded constructor regex in semantic parser (#63) --- .../Inspection/SemanticC3FactExtractor.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index d50ffa24..11e902a8 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -32,6 +32,11 @@ public static class SemanticC3FactExtractor @"(?[A-Za-z_][A-Za-z0-9_?.<>\[\],]{0,255})\s+" + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})\s*\((?[^()\r\n]*)\)\s*(?=>|\{|;)"); + private static readonly Regex ConstructorRegex = CreateRegex( + @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + + @"[ \t]*(?:(?:public|private|protected|internal|static|extern|unsafe)\s+)*" + + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})\s*\((?[^()\r\n]*)\)\s*(?=>|\{|;)"); + private static readonly Regex DiRegistrationRegex = CreateRegex( @"\bAdd(?Scoped|Singleton|Transient)\s*<\s*" + @"(?[A-Za-z_][A-Za-z0-9_.<>?,]{0,255})\s*" + @@ -560,16 +565,11 @@ private static ImmutableArray ParseMethods( absolute)); } - Regex constructorRegex = CreateRegex( - @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + - @"[ \t]*(?:(?:public|private|protected|internal|static|extern|unsafe)\s+)*" + - Regex.Escape(type.Name) + - @"\s*\((?[^()\r\n]*)\)\s*(?=>|\{|;)"); - - foreach (Match match in constructorRegex.Matches(body)) + foreach (Match match in ConstructorRegex.Matches(body)) { int absolute = type.BodyStart + match.Index; - if (braceDepth[absolute] != type.BodyDepth) + if (braceDepth[absolute] != type.BodyDepth || + !string.Equals(match.Groups["name"].Value, type.Name, StringComparison.Ordinal)) { continue; } From 556c5d296cf91451b3dccb14526d6cc947a84e62 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 16:38:00 -0300 Subject: [PATCH 07/74] fix: degrade semantic regex timeouts to diagnostics (#63) --- .../Inspection/SemanticC3FactExtractor.cs | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index 11e902a8..9e132c20 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -157,16 +157,28 @@ .. inventory.Files acceptedBytes += source.SizeBytes; string masked = MaskSource(content!, out bool complete); - SourceUnit unit = ParseUnit(project, source.RelativePath, masked, cancellationToken); - units.Add(unit); - if (!complete || unit.HasUnbalancedTypeBody) + try + { + SourceUnit unit = ParseUnit(project, source.RelativePath, masked, cancellationToken); + units.Add(unit); + + if (!complete || unit.HasUnbalancedTypeBody) + { + diagnostics.Add( + "semanticC3.partialSyntax", + DiagnosticSeverity.Info, + source.RelativePath, + "C# source is incomplete or partially parseable; only bounded structural facts were retained."); + } + } + catch (RegexMatchTimeoutException) { diagnostics.Add( - "semanticC3.partialSyntax", - DiagnosticSeverity.Info, + "semanticC3.parseTimeout", + DiagnosticSeverity.Warning, source.RelativePath, - "C# source is incomplete or partially parseable; only bounded structural facts were retained."); + "C# structural parsing exceeded its bounded regex budget; the source file was skipped."); } } @@ -181,7 +193,18 @@ .. inventory.Files break; } - AnalyzeUnit(unit, projectTypes, facts, diagnostics, cancellationToken); + try + { + AnalyzeUnit(unit, projectTypes, facts, diagnostics, cancellationToken); + } + catch (RegexMatchTimeoutException) + { + diagnostics.Add( + "semanticC3.analysisTimeout", + DiagnosticSeverity.Warning, + unit.SourcePath, + "C# structural analysis exceeded its bounded regex budget; remaining facts for the source file were skipped."); + } } return CreateResult( From f1f994bb332ef09aa00893252bb1269be16690fc Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:07:23 -0300 Subject: [PATCH 08/74] feat: enrich semantic HTTP wiring facts (#64) --- .../Contracts/SemanticC3FactContracts.cs | 3 + .../Inspection/SemanticC3FactExtractor.cs | 175 ++++++++++++++++++ .../SemanticC3FactExtractorTests.cs | 50 +++++ 3 files changed, 228 insertions(+) diff --git a/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs b/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs index 58d2fc97..3b2ebe3d 100644 --- a/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs +++ b/src/Repo2C4.Core/Contracts/SemanticC3FactContracts.cs @@ -15,6 +15,9 @@ public enum SemanticC3FactKind ConstructorInjection, HttpBoundary, ControllerAction, + MethodParameter, + EndpointDependency, + EndpointHandler, HostedService, PersistenceCandidate, MessagingCandidate, diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index 9e132c20..e0014520 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -15,6 +15,7 @@ public static class SemanticC3FactExtractor private const int MaxIdentifierLength = 128; private const int MaxAttributesPerSymbol = 32; private const int MaxBaseTypesPerType = 32; + private const int MaxEndpointCallLength = 4_096; private static readonly Regex NamespaceRegex = CreateRegex( @"\bnamespace\s+(?[A-Za-z_][A-Za-z0-9_]{0,127}(?:\.[A-Za-z_][A-Za-z0-9_]{0,127})*)\s*(?:;|\{)"); @@ -55,6 +56,9 @@ public static class SemanticC3FactExtractor private static readonly Regex AttributeNameRegex = CreateRegex( @"\b(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:Attribute)?\b"); + private static readonly Regex EndpointHandlerRegex = CreateRegex( + @"^(?[A-Za-z_][A-Za-z0-9_.]{0,255})\.(?[A-Za-z_][A-Za-z0-9_]{0,127})$"); + public static SemanticC3FactSet Extract( SemanticC3FactExtractionOptions options, CancellationToken cancellationToken = default) @@ -337,6 +341,19 @@ private static void AnalyzeUnit( : "Declares method " + method.Name + ".", null)); + for (int parameterIndex = 0; parameterIndex < method.Parameters.Length; parameterIndex++) + { + ParameterDeclaration parameter = method.Parameters[parameterIndex]; + facts.AddObservation(Fact( + unit, + method.Line, + methodSymbol, + SemanticC3FactKind.MethodParameter, + "semantic.symbol.parameter", + "Method declares parameter type " + parameter.Type + ".", + "T:" + parameter.Type + ":" + parameterIndex)); + } + foreach (string attribute in method.Attributes.Take(MaxAttributesPerSymbol)) { facts.AddObservation(Fact( @@ -428,6 +445,8 @@ private static void AnalyzeTopLevelSignals( "semantic.host.minimalApi", "Source invokes ASP.NET Core " + api + " route mapping.", "route:" + api + ":" + routeOrdinal)); + + AnalyzeEndpointDetails(unit, match, source, facts); routeOrdinal++; } @@ -474,6 +493,162 @@ private static void AnalyzeTopLevelSignals( } } + private static void AnalyzeEndpointDetails( + SourceUnit unit, + Match routeMatch, + SemanticC3SourceSymbolIdentity source, + FactAccumulator facts) + { + int openParenthesis = routeMatch.Index + routeMatch.Length - 1; + int closeParenthesis = FindMatchingParenthesisBounded(unit.MaskedSource, openParenthesis); + if (closeParenthesis < 0) + { + return; + } + + string arguments = unit.MaskedSource[(openParenthesis + 1)..closeParenthesis]; + int lambdaArrow = arguments.IndexOf("=>", StringComparison.Ordinal); + if (lambdaArrow >= 0) + { + int parameterClose = arguments.LastIndexOf(')', lambdaArrow); + if (parameterClose < 0) + { + return; + } + + int parameterOpen = FindMatchingOpenParenthesis(arguments, parameterClose); + if (parameterOpen < 0) + { + return; + } + + ParameterDeclaration[] parameters = ParseParameters( + arguments[(parameterOpen + 1)..parameterClose]); + + for (int parameterIndex = 0; parameterIndex < parameters.Length; parameterIndex++) + { + ParameterDeclaration parameter = parameters[parameterIndex]; + facts.AddRelation(Fact( + unit, + unit.LineAt(routeMatch.Index), + source, + SemanticC3FactKind.EndpointDependency, + "semantic.wiring.endpointDependency", + "HTTP endpoint lambda depends on parameter type " + parameter.Type + ".", + "T:" + parameter.Type + ":" + parameterIndex)); + } + + return; + } + + string? handlerArgument = LastTopLevelArgument(arguments); + if (handlerArgument is null) + { + return; + } + + Match handler = EndpointHandlerRegex.Match(handlerArgument); + if (!handler.Success) + { + return; + } + + string typeName = handler.Groups["type"].Value; + string methodName = handler.Groups["method"].Value; + facts.AddRelation(Fact( + unit, + unit.LineAt(routeMatch.Index), + source, + SemanticC3FactKind.EndpointHandler, + "semantic.host.endpointHandler", + "HTTP endpoint maps to explicit handler " + typeName + "." + methodName + ".", + "M:" + typeName + "." + methodName)); + } + + private static int FindMatchingParenthesisBounded(string source, int openParenthesis) + { + int depth = 0; + int limit = Math.Min(source.Length, openParenthesis + MaxEndpointCallLength); + + for (int index = openParenthesis; index < limit; index++) + { + if (source[index] == '(') + { + depth++; + } + else if (source[index] == ')' && --depth == 0) + { + return index; + } + } + + return -1; + } + + private static int FindMatchingOpenParenthesis(string value, int closeParenthesis) + { + int depth = 0; + for (int index = closeParenthesis; index >= 0; index--) + { + if (value[index] == ')') + { + depth++; + } + else if (value[index] == '(' && --depth == 0) + { + return index; + } + } + + return -1; + } + + private static string? LastTopLevelArgument(string value) + { + int start = 0; + int round = 0; + int square = 0; + int curly = 0; + int angle = 0; + + for (int index = 0; index < value.Length; index++) + { + switch (value[index]) + { + case '(': + round++; + break; + case ')': + round = Math.Max(0, round - 1); + break; + case '[': + square++; + break; + case ']': + square = Math.Max(0, square - 1); + break; + case '{': + curly++; + break; + case '}': + curly = Math.Max(0, curly - 1); + break; + case '<': + angle++; + break; + case '>': + angle = Math.Max(0, angle - 1); + break; + case ',' when round == 0 && square == 0 && curly == 0 && angle == 0: + start = index + 1; + break; + } + } + + string candidate = value[start..].Trim(); + return candidate.Length == 0 ? null : candidate; + } + private static SourceUnit ParseUnit( string projectPath, string sourcePath, diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index bb5de46b..bdc4dd23 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -98,6 +98,56 @@ public interface IOrderConsumer { } }); } + [Fact] + public void ExtractsMinimalApiLambdaDependenciesAndExplicitHandlers() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("Program.cs", """ + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddScoped(); + var app = builder.Build(); + + app.MapPost("/inline", (IOrderService service) => Results.Ok()); + app.MapGet("/explicit", OrderEndpoints.Handle); + """); + fixture.Add("Endpoints.cs", """ + public static class OrderEndpoints + { + public static object Handle(IOrderService service) => new(); + } + + public interface IOrderService + { + void Execute(); + } + + public sealed class OrderService : IOrderService + { + public void Execute() { } + } + """); + + SemanticC3FactSet result = Extract(fixture); + + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.EndpointDependency && + fact.RelatedSymbolId != null && + fact.RelatedSymbolId.StartsWith("T:IOrderService:", StringComparison.Ordinal)); + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.EndpointHandler && + fact.RelatedSymbolId == "M:OrderEndpoints.Handle"); + + SemanticC3Fact handler = Assert.Single(result.Facts.Where(fact => + fact.Kind == SemanticC3FactKind.MethodDeclaration && + fact.SourceSymbol.SymbolId.StartsWith("M:OrderEndpoints.Handle(", StringComparison.Ordinal))); + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.MethodParameter && + fact.SourceSymbol.Id == handler.SourceSymbol.Id && + fact.RelatedSymbolId != null && + fact.RelatedSymbolId.StartsWith("T:IOrderService:", StringComparison.Ordinal)); + } + [Fact] public void OutputIsDeterministicAndStableAcrossRepeatedExtraction() { From 4632b9b57c41b7cf5bfcad1261ac03fb6b4edb69 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:10:50 -0300 Subject: [PATCH 09/74] feat: propose semantic HTTP and application C3 components (#64) --- CHANGELOG.md | 1 + .../C3/SemanticC3HttpApplicationProposer.cs | 1062 +++++++++++++++++ .../SemanticC3HttpApplicationProposerTests.cs | 222 ++++ 3 files changed, 1285 insertions(+) create mode 100644 src/Repo2C4.Core/C3/SemanticC3HttpApplicationProposer.cs create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3HttpApplicationProposerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 005de0ed..54698282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Reviewable Semantic C3 HTTP/application proposals that aggregate routes by semantic boundary and distinguish DI-/handler-backed application services from name-only classes. - Bounded, cancellation-aware C# structural fact extraction for Semantic C3, covering symbols, DI/wiring, Minimal API/controllers, hosted services, DbContext, messaging role signals and conservative intra-project collaboration facts without build/code execution or source/literal propagation. - Additive, versioned Semantic C3 contract foundation with deterministic component taxonomy, stable source-symbol identity, reviewable internal/external relations, conceptual multi-container selection, and a reproducible v1.1.0 dogfooding baseline. diff --git a/src/Repo2C4.Core/C3/SemanticC3HttpApplicationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3HttpApplicationProposer.cs new file mode 100644 index 00000000..c855ea04 --- /dev/null +++ b/src/Repo2C4.Core/C3/SemanticC3HttpApplicationProposer.cs @@ -0,0 +1,1062 @@ +using System.Collections.Immutable; +using System.Text; +using Repo2C4.Core.Contracts; + +namespace Repo2C4.Core.C3; + +/// +/// Proposes reviewable Semantic C3 HTTP and application-layer components from bounded structural facts. +/// HTTP routes are aggregated by controller type or by the enclosing Minimal API semantic symbol; a route is +/// never promoted to a standalone component merely because it appears in source. +/// +public static class SemanticC3HttpApplicationProposer +{ + private static readonly string[] InfrastructureSuffixes = + [ + "Adapter", + "Client", + "Consumer", + "Context", + "DbContext", + "Producer", + "Publisher", + "Repository", + "Subscriber", + "Worker", + ]; + + public static SemanticC3Proposal Propose( + ArchitectureModel baseModel, + string selectedContainerId, + SemanticC3FactSet factSet) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); + ArgumentNullException.ThrowIfNull(factSet); + + ThrowIfInvalidBase(baseModel); + ValidateFactSet(factSet); + + ArchitectureElement? selected = baseModel.Elements.FirstOrDefault(element => + element.Id == selectedContainerId && + element.Kind == ArchitectureElementKind.Container); + if (selected is null) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.containerMissing", + "$.selectedContainerIds", + "Selected Semantic C3 container must exist in the C2 base model."), + ]); + } + + HashSet selectedProjects = ResolveSelectedProjects(baseModel, selected); + if (selectedProjects.Count == 0) + { + return new SemanticC3Proposal( + SemanticC3ContractSchema.Version, + [selectedContainerId], + [], + []); + } + + SemanticC3Fact[] facts = + [ + .. factSet.Facts + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + + BoundarySeed[] boundaries = BuildBoundaries(facts, selectedProjects); + List components = + [ + .. boundaries.Select(boundary => + CreateHttpCandidate(selected, boundary)), + ]; + + components.AddRange(BuildApplicationCandidates( + selected, + facts, + selectedProjects, + boundaries)); + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [selectedContainerId], + [.. components + .GroupBy(component => component.Id, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(component => component.Id, StringComparer.Ordinal)], + []); + + ImmutableArray errors = SemanticC3Validator.Validate(proposal); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return proposal; + } + + private static BoundarySeed[] BuildBoundaries( + SemanticC3Fact[] facts, + HashSet selectedProjects) + { + Dictionary boundaries = new(StringComparer.Ordinal); + + foreach (SemanticC3Fact fact in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Category == "semantic.host.minimalApi")) + { + AddBoundary( + boundaries, + fact.SourceSymbol, + BoundaryKind.MinimalApi, + fact); + } + + foreach (SemanticC3Fact fact in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Category == "semantic.host.controller")) + { + AddBoundary( + boundaries, + fact.SourceSymbol, + BoundaryKind.Controller, + fact); + } + + foreach (SemanticC3Fact fact in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Category == "semantic.host.controllerAction")) + { + SemanticC3SourceSymbolIdentity? typeSymbol = DeclaringTypeIdentity(fact.SourceSymbol); + if (typeSymbol is null) + { + continue; + } + + BoundaryAccumulator boundary = AddBoundary( + boundaries, + typeSymbol, + BoundaryKind.Controller, + fact); + boundary.ActionSymbolIds.Add(fact.SourceSymbol.Id); + } + + return + [ + .. boundaries.Values + .Select(boundary => boundary.ToSeed()) + .OrderBy(boundary => boundary.SourceSymbol.Id, StringComparer.Ordinal), + ]; + } + + private static List BuildApplicationCandidates( + ArchitectureElement selected, + SemanticC3Fact[] facts, + HashSet selectedProjects, + BoundarySeed[] boundaries) + { + Dictionary signals = BuildEndpointSignals( + facts, + selectedProjects, + boundaries); + + DiRegistration[] registrations = + [ + .. facts + .Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Kind == SemanticC3FactKind.DependencyInjectionRegistration) + .Select(ParseRegistration) + .Where(registration => registration is not null) + .Select(registration => registration!) + .OrderBy(registration => registration.Fact.Id, StringComparer.Ordinal), + ]; + + SemanticC3Fact[] typeFacts = + [ + .. facts.Where(fact => fact.Kind == SemanticC3FactKind.TypeDeclaration), + ]; + + Dictionary candidates = new(StringComparer.Ordinal); + HashSet minimalProjects = boundaries + .Where(boundary => boundary.Kind == BoundaryKind.MinimalApi) + .Select(boundary => boundary.ProjectPath) + .ToHashSet(StringComparer.Ordinal); + + foreach (DiRegistration registration in registrations) + { + SemanticC3Fact? typeFact = ResolveConcreteType(typeFacts, registration.ImplementationType); + if (typeFact is null || + IsInfrastructureType(typeFact, facts) || + !HasApplicationMethod(typeFact, facts)) + { + continue; + } + + SignalAccumulator? signal = FindSignal( + signals, + registration.ServiceType, + registration.ImplementationType); + + bool coLocatedMinimalApi = signal is null && + minimalProjects.Contains(registration.Fact.ProjectPath); + if (signal is null && !coLocatedMinimalApi) + { + continue; + } + + ApplicationAccumulator accumulator = GetApplicationAccumulator( + candidates, + typeFact, + selected.Id); + + accumulator.EvidenceIds.Add(typeFact.Id); + accumulator.EvidenceIds.Add(registration.Fact.Id); + + if (signal is not null) + { + accumulator.StrongEndpointSignal = true; + foreach (string evidenceId in signal.EvidenceIds) + { + accumulator.EvidenceIds.Add(evidenceId); + } + } + else + { + accumulator.CoLocatedOnly = true; + foreach (BoundarySeed boundary in boundaries.Where(boundary => + boundary.Kind == BoundaryKind.MinimalApi && + boundary.ProjectPath == registration.Fact.ProjectPath)) + { + foreach (string evidenceId in boundary.EvidenceIds) + { + accumulator.EvidenceIds.Add(evidenceId); + } + } + } + } + + foreach ((string targetType, SignalAccumulator signal) in signals + .Where(item => item.Value.DirectInvocation) + .OrderBy(item => item.Key, StringComparer.Ordinal)) + { + SemanticC3Fact? typeFact = ResolveConcreteType(typeFacts, targetType); + if (typeFact is null || + IsInfrastructureType(typeFact, facts) || + !HasApplicationMethod(typeFact, facts)) + { + continue; + } + + ApplicationAccumulator accumulator = GetApplicationAccumulator( + candidates, + typeFact, + selected.Id); + accumulator.StrongEndpointSignal = true; + accumulator.EvidenceIds.Add(typeFact.Id); + foreach (string evidenceId in signal.EvidenceIds) + { + accumulator.EvidenceIds.Add(evidenceId); + } + } + + return + [ + .. candidates.Values + .Select(CreateApplicationCandidate) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal), + ]; + } + + private static Dictionary BuildEndpointSignals( + SemanticC3Fact[] facts, + HashSet selectedProjects, + BoundarySeed[] boundaries) + { + Dictionary signals = + new(StringComparer.OrdinalIgnoreCase); + + HashSet minimalBoundarySymbols = boundaries + .Where(boundary => boundary.Kind == BoundaryKind.MinimalApi) + .Select(boundary => boundary.SourceSymbol.Id) + .ToHashSet(StringComparer.Ordinal); + + Dictionary controllerByTypeSymbol = boundaries + .Where(boundary => boundary.Kind == BoundaryKind.Controller) + .ToDictionary( + boundary => boundary.SourceSymbol.Id, + StringComparer.Ordinal); + + foreach (SemanticC3Fact dependency in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Kind == SemanticC3FactKind.EndpointDependency && + minimalBoundarySymbols.Contains(fact.SourceSymbol.Id))) + { + string? typeName = RelatedTypeName(dependency.RelatedSymbolId); + if (typeName is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, typeName); + signal.EvidenceIds.Add(dependency.Id); + AddBoundaryEvidence(signal, boundaries, dependency.SourceSymbol.Id); + } + + foreach (BoundarySeed controller in controllerByTypeSymbol.Values) + { + foreach (SemanticC3Fact injection in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Kind == SemanticC3FactKind.ConstructorInjection && + DeclaringTypeId(fact.SourceSymbol.SymbolId) == controller.SourceSymbol.SymbolId)) + { + string? typeName = RelatedTypeName(injection.RelatedSymbolId); + if (typeName is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, typeName); + signal.EvidenceIds.Add(injection.Id); + AddBoundaryEvidence(signal, controller); + } + + foreach (SemanticC3Fact parameter in facts.Where(fact => + fact.Kind == SemanticC3FactKind.MethodParameter && + controller.ActionSymbolIds.Contains(fact.SourceSymbol.Id))) + { + string? typeName = RelatedTypeName(parameter.RelatedSymbolId); + if (typeName is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, typeName); + signal.EvidenceIds.Add(parameter.Id); + AddBoundaryEvidence(signal, controller); + } + + foreach (SemanticC3Fact invocation in facts.Where(fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation && + controller.ActionSymbolIds.Contains(fact.SourceSymbol.Id))) + { + string? targetType = RelatedMethodTypeName(invocation.RelatedSymbolId); + if (targetType is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, targetType); + signal.DirectInvocation = true; + signal.EvidenceIds.Add(invocation.Id); + AddBoundaryEvidence(signal, controller); + } + } + + foreach (SemanticC3Fact handler in facts.Where(fact => + selectedProjects.Contains(fact.ProjectPath) && + fact.Kind == SemanticC3FactKind.EndpointHandler && + minimalBoundarySymbols.Contains(fact.SourceSymbol.Id))) + { + SemanticC3Fact[] handlerMethods = + [ + .. facts.Where(fact => + fact.Kind == SemanticC3FactKind.MethodDeclaration && + MatchesMethodTarget(fact.SourceSymbol.SymbolId, handler.RelatedSymbolId)), + ]; + + if (handlerMethods.Length != 1) + { + continue; + } + + SemanticC3Fact method = handlerMethods[0]; + foreach (SemanticC3Fact parameter in facts.Where(fact => + fact.Kind == SemanticC3FactKind.MethodParameter && + fact.SourceSymbol.Id == method.SourceSymbol.Id)) + { + string? typeName = RelatedTypeName(parameter.RelatedSymbolId); + if (typeName is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, typeName); + signal.EvidenceIds.Add(handler.Id); + signal.EvidenceIds.Add(parameter.Id); + AddBoundaryEvidence(signal, boundaries, handler.SourceSymbol.Id); + } + + foreach (SemanticC3Fact invocation in facts.Where(fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation && + fact.SourceSymbol.Id == method.SourceSymbol.Id)) + { + string? targetType = RelatedMethodTypeName(invocation.RelatedSymbolId); + if (targetType is null) + { + continue; + } + + SignalAccumulator signal = GetSignal(signals, targetType); + signal.DirectInvocation = true; + signal.EvidenceIds.Add(handler.Id); + signal.EvidenceIds.Add(invocation.Id); + AddBoundaryEvidence(signal, boundaries, handler.SourceSymbol.Id); + } + } + + return signals; + } + + private static SemanticC3ComponentCandidate CreateHttpCandidate( + ArchitectureElement selected, + BoundarySeed boundary) + { + string name = HttpCandidateName(selected.Name, boundary); + string responsibility = boundary.Kind == BoundaryKind.Controller + ? "Receives HTTP requests through a controller boundary and exposes application entry points." + : "Receives HTTP requests through Minimal API mappings and exposes application entry points."; + + return new SemanticC3ComponentCandidate( + StableIds.ForSemanticC3Component( + selected.Id, + SemanticC3ComponentCategory.HttpEndpoint, + boundary.SourceSymbol.Id), + selected.Id, + SemanticC3ComponentCategory.HttpEndpoint, + name, + responsibility, + [.. boundary.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + boundary.SourceSymbol, + ReviewStatus.RequiresReview, + boundary.Kind == BoundaryKind.Controller + ? "Controller routes are aggregated by declaring controller type; the resulting C3 boundary requires architectural review." + : "Minimal API routes are aggregated by their enclosing semantic symbol; the resulting C3 boundary requires architectural review."); + } + + private static SemanticC3ComponentCandidate CreateApplicationCandidate( + ApplicationAccumulator candidate) + { + string simpleName = SimpleTypeNameFromSymbol(candidate.TypeFact.SourceSymbol.SymbolId); + string name = ApplicationCandidateName(simpleName); + + string reviewReason = candidate.StrongEndpointSignal + ? "HTTP dependency/handler or invocation evidence converges on this application type; the C3 responsibility boundary remains reviewable." + : "The type is DI-registered inside a Minimal API host project, but endpoint-level usage was not observed; keep this application candidate under review."; + + return new SemanticC3ComponentCandidate( + StableIds.ForSemanticC3Component( + candidate.ContainerId, + SemanticC3ComponentCategory.ApplicationService, + candidate.TypeFact.SourceSymbol.Id), + candidate.ContainerId, + SemanticC3ComponentCategory.ApplicationService, + name, + "Coordinates application behavior reached from the HTTP boundary.", + [.. candidate.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + candidate.TypeFact.SourceSymbol, + ReviewStatus.RequiresReview, + reviewReason); + } + + private static BoundaryAccumulator AddBoundary( + Dictionary boundaries, + SemanticC3SourceSymbolIdentity sourceSymbol, + BoundaryKind kind, + SemanticC3Fact fact) + { + string key = sourceSymbol.Id; + if (!boundaries.TryGetValue(key, out BoundaryAccumulator? boundary)) + { + boundary = new BoundaryAccumulator(sourceSymbol, kind); + boundaries.Add(key, boundary); + } + + if (kind == BoundaryKind.Controller) + { + boundary.Kind = BoundaryKind.Controller; + } + + boundary.EvidenceIds.Add(fact.Id); + return boundary; + } + + private static void AddBoundaryEvidence( + SignalAccumulator signal, + BoundarySeed[] boundaries, + string sourceSymbolId) + { + foreach (BoundarySeed boundary in boundaries.Where(boundary => + boundary.SourceSymbol.Id == sourceSymbolId)) + { + AddBoundaryEvidence(signal, boundary); + } + } + + private static void AddBoundaryEvidence( + SignalAccumulator signal, + BoundarySeed boundary) + { + foreach (string evidenceId in boundary.EvidenceIds) + { + signal.EvidenceIds.Add(evidenceId); + } + } + + private static SignalAccumulator GetSignal( + Dictionary signals, + string typeName) + { + string key = SimpleTypeName(typeName); + if (!signals.TryGetValue(key, out SignalAccumulator? signal)) + { + signal = new SignalAccumulator(); + signals.Add(key, signal); + } + + return signal; + } + + private static SignalAccumulator? FindSignal( + Dictionary signals, + string serviceType, + string implementationType) + { + if (signals.TryGetValue(SimpleTypeName(serviceType), out SignalAccumulator? service)) + { + return service; + } + + return signals.TryGetValue(SimpleTypeName(implementationType), out SignalAccumulator? implementation) + ? implementation + : null; + } + + private static ApplicationAccumulator GetApplicationAccumulator( + Dictionary candidates, + SemanticC3Fact typeFact, + string containerId) + { + if (!candidates.TryGetValue(typeFact.SourceSymbol.Id, out ApplicationAccumulator? candidate)) + { + candidate = new ApplicationAccumulator(typeFact, containerId); + candidates.Add(typeFact.SourceSymbol.Id, candidate); + } + + return candidate; + } + + private static DiRegistration? ParseRegistration(SemanticC3Fact fact) + { + string? related = fact.RelatedSymbolId; + if (related is null || !related.StartsWith("DI:", StringComparison.Ordinal)) + { + return null; + } + + int lifetimeSeparator = related.IndexOf(':', 3); + int arrow = related.IndexOf("->", StringComparison.Ordinal); + int ordinalSeparator = related.LastIndexOf(':'); + + if (lifetimeSeparator < 0 || + arrow <= lifetimeSeparator || + ordinalSeparator <= arrow + 2) + { + return null; + } + + string service = related[(lifetimeSeparator + 1)..arrow]; + string implementation = related[(arrow + 2)..ordinalSeparator]; + + return string.IsNullOrWhiteSpace(service) || string.IsNullOrWhiteSpace(implementation) + ? null + : new DiRegistration(service, implementation, fact); + } + + private static SemanticC3Fact? ResolveConcreteType( + SemanticC3Fact[] typeFacts, + string typeName) + { + string normalized = NormalizeTypeName(typeName); + string simple = SimpleTypeName(normalized); + + SemanticC3Fact[] candidates = + [ + .. typeFacts.Where(fact => + !fact.Description.StartsWith("Declares interface", StringComparison.Ordinal) && + (fact.SourceSymbol.SymbolId == "T:" + normalized || + SimpleTypeNameFromSymbol(fact.SourceSymbol.SymbolId) + .Equals(simple, StringComparison.Ordinal))), + ]; + + SemanticC3Fact[] exact = + [ + .. candidates.Where(fact => + fact.SourceSymbol.SymbolId == "T:" + normalized), + ]; + + if (exact.Length == 1) + { + return exact[0]; + } + + return candidates.Length == 1 ? candidates[0] : null; + } + + private static bool IsInfrastructureType( + SemanticC3Fact typeFact, + SemanticC3Fact[] facts) + { + if (facts.Any(fact => + fact.SourceSymbol.Id == typeFact.SourceSymbol.Id && + fact.Kind is SemanticC3FactKind.HostedService + or SemanticC3FactKind.PersistenceCandidate + or SemanticC3FactKind.MessagingCandidate + or SemanticC3FactKind.HttpBoundary)) + { + return true; + } + + string simpleName = SimpleTypeNameFromSymbol(typeFact.SourceSymbol.SymbolId); + return InfrastructureSuffixes.Any(suffix => + simpleName.EndsWith(suffix, StringComparison.Ordinal)); + } + + private static bool HasApplicationMethod( + SemanticC3Fact typeFact, + SemanticC3Fact[] facts) + { + string qualified = typeFact.SourceSymbol.SymbolId["T:".Length..]; + string methodPrefix = "M:" + qualified + "."; + + return facts.Any(fact => + fact.Kind == SemanticC3FactKind.MethodDeclaration && + fact.SourceSymbol.SymbolId.StartsWith(methodPrefix, StringComparison.Ordinal)); + } + + private static HashSet ResolveSelectedProjects( + ArchitectureModel model, + ArchitectureElement selected) + { + Dictionary evidenceById = model.Snapshot.Evidence + .ToDictionary(evidence => evidence.Id, StringComparer.Ordinal); + string[] projectPaths = + [ + .. model.Snapshot.Files + .Select(file => file.RelativePath) + .Where(path => Path.GetExtension(path) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal), + ]; + + HashSet selectedProjects = new(StringComparer.Ordinal); + foreach (string evidenceId in selected.EvidenceIds) + { + if (!evidenceById.TryGetValue(evidenceId, out Evidence? evidence)) + { + continue; + } + + if (Path.GetExtension(evidence.RelativePath) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + { + selectedProjects.Add(evidence.RelativePath); + continue; + } + + string? nearest = projectPaths + .Where(project => IsWithinProject(evidence.RelativePath, project)) + .OrderByDescending(ProjectDepth) + .ThenBy(project => project, StringComparer.Ordinal) + .FirstOrDefault(); + if (nearest is not null) + { + selectedProjects.Add(nearest); + } + } + + return selectedProjects; + } + + private static bool IsWithinProject(string sourcePath, string projectPath) + { + string directory = DirectoryPart(projectPath); + return directory.Length == 0 || + sourcePath.StartsWith(directory + "/", StringComparison.Ordinal) || + sourcePath.Equals(directory, StringComparison.Ordinal); + } + + private static int ProjectDepth(string projectPath) => + DirectoryPart(projectPath).Count(character => character == '/') + 1; + + private static string DirectoryPart(string path) + { + int separator = path.LastIndexOf('/'); + return separator < 0 ? string.Empty : path[..separator]; + } + + private static SemanticC3SourceSymbolIdentity? DeclaringTypeIdentity( + SemanticC3SourceSymbolIdentity method) + { + string? typeId = DeclaringTypeId(method.SymbolId); + return typeId is null + ? null + : new SemanticC3SourceSymbolIdentity( + StableIds.ForSemanticC3SourceSymbol(method.ProjectPath, typeId), + method.ProjectPath, + typeId); + } + + private static string? DeclaringTypeId(string methodSymbolId) + { + if (!methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + int parameters = methodSymbolId.IndexOf('('); + string head = parameters >= 0 + ? methodSymbolId[..parameters] + : methodSymbolId; + int methodSeparator = head.LastIndexOf('.'); + if (methodSeparator <= 2) + { + return null; + } + + return "T:" + head["M:".Length..methodSeparator]; + } + + private static string? RelatedTypeName(string? relatedSymbolId) + { + if (relatedSymbolId is null || + !relatedSymbolId.StartsWith("T:", StringComparison.Ordinal)) + { + return null; + } + + int ordinalSeparator = relatedSymbolId.LastIndexOf(':'); + if (ordinalSeparator <= 2) + { + return null; + } + + return relatedSymbolId[2..ordinalSeparator]; + } + + private static string? RelatedMethodTypeName(string? relatedSymbolId) + { + if (relatedSymbolId is null || + !relatedSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + string target = relatedSymbolId[2..]; + int methodSeparator = target.LastIndexOf('.'); + if (methodSeparator <= 0) + { + return null; + } + + return target[..methodSeparator]; + } + + private static bool MatchesMethodTarget( + string methodSymbolId, + string? relatedTarget) + { + if (relatedTarget is null || + !relatedTarget.StartsWith("M:", StringComparison.Ordinal) || + !methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return false; + } + + int parameters = methodSymbolId.IndexOf('('); + string methodHead = parameters >= 0 + ? methodSymbolId[..parameters] + : methodSymbolId; + string target = relatedTarget[2..]; + string candidate = methodHead[2..]; + + return candidate.Equals(target, StringComparison.Ordinal) || + candidate.EndsWith("." + target, StringComparison.Ordinal); + } + + private static string HttpCandidateName( + string selectedContainerName, + BoundarySeed boundary) + { + string semanticName; + if (boundary.Kind == BoundaryKind.Controller) + { + semanticName = SimpleTypeNameFromSymbol(boundary.SourceSymbol.SymbolId); + semanticName = TrimSuffix(semanticName, "Controller"); + } + else + { + semanticName = MethodNameFromSymbol(boundary.SourceSymbol.SymbolId); + if (semanticName is "" or "Program" or "") + { + semanticName = selectedContainerName; + } + else + { + semanticName = TrimPrefix(semanticName, "Map"); + semanticName = TrimPrefix(semanticName, "Configure"); + semanticName = TrimPrefix(semanticName, "Register"); + semanticName = TrimSuffix(semanticName, "Endpoints"); + } + } + + string humanized = Humanize(semanticName); + if (humanized.Length == 0) + { + humanized = "HTTP"; + } + + return Truncate(humanized + " HTTP endpoint", 120); + } + + private static string ApplicationCandidateName(string simpleName) + { + if (simpleName.EndsWith("UseCase", StringComparison.Ordinal)) + { + return Truncate( + Humanize(TrimSuffix(simpleName, "UseCase")) + " use case", + 120); + } + + if (simpleName.EndsWith("Handler", StringComparison.Ordinal)) + { + return Truncate( + Humanize(TrimSuffix(simpleName, "Handler")) + " handler", + 120); + } + + string baseName = TrimSuffix(simpleName, "Service"); + string humanized = Humanize(baseName); + if (humanized.Length == 0) + { + humanized = "Application"; + } + + return Truncate(humanized + " application service", 120); + } + + private static string MethodNameFromSymbol(string symbolId) + { + if (!symbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return string.Empty; + } + + int parameters = symbolId.IndexOf('('); + string head = parameters >= 0 ? symbolId[..parameters] : symbolId; + int separator = head.LastIndexOf('.'); + return separator < 0 ? string.Empty : head[(separator + 1)..]; + } + + private static string SimpleTypeNameFromSymbol(string symbolId) => + symbolId.StartsWith("T:", StringComparison.Ordinal) + ? SimpleTypeName(symbolId[2..]) + : SimpleTypeName(symbolId); + + private static string NormalizeTypeName(string value) + { + string normalized = value.Trim() + .Replace("global::", string.Empty, StringComparison.Ordinal) + .TrimEnd('?'); + + int generic = normalized.IndexOf('<'); + if (generic >= 0) + { + normalized = normalized[..generic]; + } + + while (normalized.EndsWith("[]", StringComparison.Ordinal)) + { + normalized = normalized[..^2]; + } + + return normalized; + } + + private static string SimpleTypeName(string value) + { + string normalized = NormalizeTypeName(value); + int separator = normalized.LastIndexOf('.'); + return separator >= 0 ? normalized[(separator + 1)..] : normalized; + } + + private static string Humanize(string value) + { + StringBuilder builder = new(); + char previous = '\0'; + + foreach (char character in value) + { + if (!char.IsLetterOrDigit(character)) + { + AppendSpace(builder); + previous = character; + continue; + } + + if (char.IsUpper(character) && + builder.Length > 0 && + (char.IsLower(previous) || char.IsDigit(previous))) + { + AppendSpace(builder); + } + + builder.Append(character); + previous = character; + } + + return builder.ToString().Trim(); + } + + private static void AppendSpace(StringBuilder builder) + { + if (builder.Length > 0 && builder[^1] != ' ') + { + builder.Append(' '); + } + } + + private static string TrimPrefix(string value, string prefix) => + value.StartsWith(prefix, StringComparison.Ordinal) && + value.Length > prefix.Length + ? value[prefix.Length..] + : value; + + private static string TrimSuffix(string value, string suffix) => + value.EndsWith(suffix, StringComparison.Ordinal) && + value.Length > suffix.Length + ? value[..^suffix.Length] + : value; + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength + ? value + : value[..maxLength].TrimEnd(); + + private static void ThrowIfInvalidBase(ArchitectureModel baseModel) + { + ImmutableArray errors = ContractValidator.ValidateModel(baseModel); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + } + + private static void ValidateFactSet(SemanticC3FactSet factSet) + { + if (factSet.SchemaVersion != SemanticC3ContractSchema.Version) + { + throw new ContractValidationException( + [ + new ContractError( + "schema.unsupported", + "$.facts.schemaVersion", + "Only Semantic C3 fact schema version " + + SemanticC3ContractSchema.Version + + " is supported."), + ]); + } + + if (factSet.Facts.IsDefault || factSet.Diagnostics.IsDefault) + { + throw new ContractValidationException( + [ + new ContractError( + "collection.missing", + "$.facts", + "Semantic C3 facts and diagnostics must be initialized collections."), + ]); + } + } + + private enum BoundaryKind + { + MinimalApi, + Controller, + } + + private sealed record BoundarySeed( + SemanticC3SourceSymbolIdentity SourceSymbol, + BoundaryKind Kind, + ImmutableArray EvidenceIds, + ImmutableHashSet ActionSymbolIds) + { + public string ProjectPath => SourceSymbol.ProjectPath; + } + + private sealed class BoundaryAccumulator( + SemanticC3SourceSymbolIdentity sourceSymbol, + BoundaryKind kind) + { + public SemanticC3SourceSymbolIdentity SourceSymbol { get; } = sourceSymbol; + + public BoundaryKind Kind + { + get; + set; + } = kind; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public HashSet ActionSymbolIds { get; } = new(StringComparer.Ordinal); + + public BoundarySeed ToSeed() => + new( + SourceSymbol, + Kind, + [.. EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + ActionSymbolIds.ToImmutableHashSet(StringComparer.Ordinal)); + } + + private sealed class SignalAccumulator + { + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public bool DirectInvocation + { + get; + set; + } + } + + private sealed class ApplicationAccumulator( + SemanticC3Fact typeFact, + string containerId) + { + public SemanticC3Fact TypeFact { get; } = typeFact; + + public string ContainerId { get; } = containerId; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public bool StrongEndpointSignal + { + get; + set; + } + + public bool CoLocatedOnly + { + get; + set; + } + } + + private sealed record DiRegistration( + string ServiceType, + string ImplementationType, + SemanticC3Fact Fact); +} diff --git a/tests/Repo2C4.Core.Tests/SemanticC3HttpApplicationProposerTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3HttpApplicationProposerTests.cs new file mode 100644 index 00000000..84ddb68c --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3HttpApplicationProposerTests.cs @@ -0,0 +1,222 @@ +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3HttpApplicationProposerTests +{ + private const string ProjectPath = "src/Ingestion.Api/Ingestion.Api.csproj"; + private const string ProgramPath = "src/Ingestion.Api/Program.cs"; + private const string ContainerId = "el_ingestion_api"; + + [Fact] + public void GoldenFixtureProducesUsefulHttpAndApplicationCandidatesDeterministically() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + SemanticC3SourceSymbolIdentity service = Symbol("T:Ingestion.Api.IngestionService"); + + SemanticC3FactSet facts = FactSet( + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapPost route.", "route:MapPost:0"), + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapGet route.", "route:MapGet:1"), + Fact(program, SemanticC3FactKind.EndpointDependency, "semantic.wiring.endpointDependency", "Endpoint depends on IIngestionService.", "T:IIngestionService:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, "semantic.wiring.diRegistration", "DI registration.", "DI:Scoped:IIngestionService->IngestionService:0"), + Fact(service, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol IngestionService.", null), + Fact(Symbol("M:Ingestion.Api.IngestionService.Execute()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Execute.", null)); + + SemanticC3Proposal first = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + SemanticC3Proposal repeated = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + SemanticC3ComponentCandidate http = Assert.Single(first.Components.Where(component => + component.Category == SemanticC3ComponentCategory.HttpEndpoint)); + SemanticC3ComponentCandidate application = Assert.Single(first.Components.Where(component => + component.Category == SemanticC3ComponentCategory.ApplicationService)); + + Assert.Equal("Ingestion Api HTTP endpoint", http.Name); + Assert.Equal("Ingestion application service", application.Name); + Assert.DoesNotContain("HTTP interface", http.Name, StringComparison.Ordinal); + Assert.Equal(ReviewStatus.RequiresReview, http.Status); + Assert.Equal(ReviewStatus.RequiresReview, application.Status); + Assert.Contains(facts.Facts[0].Id, http.EvidenceIds); + Assert.Contains(facts.Facts[2].Id, application.EvidenceIds); + Assert.Equal( + SemanticC3ContractJson.Serialize(first), + SemanticC3ContractJson.Serialize(repeated)); + } + + [Fact] + public void ControllerInjectionCanDistinguishTwoApplicationServices() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity controller = Symbol("T:Ingestion.Api.OrdersController"); + SemanticC3SourceSymbolIdentity action = Symbol("M:Ingestion.Api.OrdersController.Create()"); + SemanticC3SourceSymbolIdentity constructor = Symbol("M:Ingestion.Api.OrdersController.#ctor(IOrderService,IAuditHandler)"); + SemanticC3SourceSymbolIdentity orderService = Symbol("T:Ingestion.Api.OrderService"); + SemanticC3SourceSymbolIdentity auditHandler = Symbol("T:Ingestion.Api.AuditHandler"); + + SemanticC3FactSet facts = FactSet( + Fact(controller, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol OrdersController.", null), + Fact(controller, SemanticC3FactKind.HttpBoundary, "semantic.host.controller", "Controller boundary.", "controller"), + Fact(action, SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Create.", null), + Fact(action, SemanticC3FactKind.ControllerAction, "semantic.host.controllerAction", "HTTP action.", "httpAction"), + Fact(constructor, SemanticC3FactKind.ConstructorInjection, "semantic.wiring.constructorInjection", "Depends on IOrderService.", "T:IOrderService:0"), + Fact(constructor, SemanticC3FactKind.ConstructorInjection, "semantic.wiring.constructorInjection", "Depends on IAuditHandler.", "T:IAuditHandler:1"), + Fact(Symbol("M:.Program.()"), SemanticC3FactKind.DependencyInjectionRegistration, "semantic.wiring.diRegistration", "Registers order service.", "DI:Scoped:IOrderService->OrderService:0"), + Fact(Symbol("M:.Program.()"), SemanticC3FactKind.DependencyInjectionRegistration, "semantic.wiring.diRegistration", "Registers audit handler.", "DI:Scoped:IAuditHandler->AuditHandler:1"), + Fact(orderService, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol OrderService.", null), + Fact(Symbol("M:Ingestion.Api.OrderService.Execute()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Execute.", null), + Fact(auditHandler, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol AuditHandler.", null), + Fact(Symbol("M:Ingestion.Api.AuditHandler.Handle()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Handle.", null)); + + SemanticC3Proposal proposal = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + Assert.Single(proposal.Components.Where(component => + component.Category == SemanticC3ComponentCategory.HttpEndpoint)); + SemanticC3ComponentCandidate[] applications = + [ + .. proposal.Components.Where(component => + component.Category == SemanticC3ComponentCategory.ApplicationService), + ]; + Assert.Equal(2, applications.Length); + Assert.Contains(applications, component => component.Name == "Order application service"); + Assert.Contains(applications, component => component.Name == "Audit handler"); + Assert.All(applications, component => + Assert.Contains("HTTP dependency/handler", component.ReviewReason!, StringComparison.Ordinal)); + } + + [Fact] + public void ExplicitHandlerAndExtensionMethodRegistrationRemainStructurallyLinked() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + SemanticC3SourceSymbolIdentity handler = Symbol("M:Ingestion.Api.OrderEndpoints.Handle(IOrderService)"); + SemanticC3SourceSymbolIdentity registration = Symbol("M:Ingestion.Api.ServiceCollectionExtensions.AddOrders(IServiceCollection)"); + SemanticC3SourceSymbolIdentity service = Symbol("T:Ingestion.Api.OrderService"); + + SemanticC3FactSet facts = FactSet( + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapPost route.", "route:MapPost:0"), + Fact(program, SemanticC3FactKind.EndpointHandler, "semantic.host.endpointHandler", "Explicit handler.", "M:OrderEndpoints.Handle"), + Fact(handler, SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares handler.", null), + Fact(handler, SemanticC3FactKind.MethodParameter, "semantic.symbol.parameter", "Handler parameter.", "T:IOrderService:0"), + Fact(registration, SemanticC3FactKind.DependencyInjectionRegistration, "semantic.wiring.diRegistration", "Extension registration.", "DI:Scoped:IOrderService->OrderService:0"), + Fact(service, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol OrderService.", null), + Fact(Symbol("M:Ingestion.Api.OrderService.Execute()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Execute.", null)); + + SemanticC3Proposal proposal = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + SemanticC3ComponentCandidate application = Assert.Single(proposal.Components.Where(component => + component.Category == SemanticC3ComponentCategory.ApplicationService)); + Assert.Equal("Order application service", application.Name); + Assert.Contains(facts.Facts[1].Id, application.EvidenceIds); + Assert.Contains(facts.Facts[3].Id, application.EvidenceIds); + Assert.Contains(facts.Facts[4].Id, application.EvidenceIds); + } + + [Fact] + public void ServiceNameWithoutDiOrEndpointUseDoesNotBecomeAComponent() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + SemanticC3SourceSymbolIdentity lonely = Symbol("T:Ingestion.Api.LonelyService"); + + SemanticC3FactSet facts = FactSet( + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapGet route.", "route:MapGet:0"), + Fact(lonely, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol LonelyService.", null), + Fact(Symbol("M:Ingestion.Api.LonelyService.Execute()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Execute.", null)); + + SemanticC3Proposal proposal = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + Assert.Single(proposal.Components); + Assert.Equal(SemanticC3ComponentCategory.HttpEndpoint, proposal.Components[0].Category); + } + + [Fact] + public void MinimalApiWithoutExplicitApplicationLayerStillProducesOnlyHttpBoundary() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + + SemanticC3FactSet facts = FactSet( + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapGet route.", "route:MapGet:0"), + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapPost route.", "route:MapPost:1")); + + SemanticC3Proposal proposal = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + SemanticC3ComponentCandidate http = Assert.Single(proposal.Components); + Assert.Equal(SemanticC3ComponentCategory.HttpEndpoint, http.Category); + Assert.Equal(2, http.EvidenceIds.Length); + Assert.Empty(proposal.Relations); + } + + [Fact] + public void RegisteredTypeInMinimalApiProjectWithoutEndpointUseStaysReviewable() + { + ArchitectureModel model = LoadGoldenModel(); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + SemanticC3SourceSymbolIdentity service = Symbol("T:Ingestion.Api.OrderService"); + + SemanticC3FactSet facts = FactSet( + Fact(program, SemanticC3FactKind.HttpBoundary, "semantic.host.minimalApi", "MapGet route.", "route:MapGet:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, "semantic.wiring.diRegistration", "DI registration.", "DI:Scoped:IOrderService->OrderService:0"), + Fact(service, SemanticC3FactKind.TypeDeclaration, "semantic.symbol.type", "Declares class symbol OrderService.", null), + Fact(Symbol("M:Ingestion.Api.OrderService.Execute()"), SemanticC3FactKind.MethodDeclaration, "semantic.symbol.method", "Declares method Execute.", null)); + + SemanticC3Proposal proposal = SemanticC3HttpApplicationProposer.Propose(model, ContainerId, facts); + + SemanticC3ComponentCandidate application = Assert.Single(proposal.Components.Where(component => + component.Category == SemanticC3ComponentCategory.ApplicationService)); + Assert.Equal(ReviewStatus.RequiresReview, application.Status); + Assert.Contains("endpoint-level usage was not observed", application.ReviewReason!, StringComparison.Ordinal); + } + + private static ArchitectureModel LoadGoldenModel() + { + string path = Path.Combine( + AppContext.BaseDirectory, + "SemanticC3Fixtures", + "dotnet-observability-lab.ingestion.v1.1.c2.json"); + return ContractJson.DeserializeModel(File.ReadAllText(path)); + } + + private static SemanticC3FactSet FactSet(params SemanticC3Fact[] facts) => + new( + SemanticC3ContractSchema.Version, + [.. facts], + []); + + private static SemanticC3SourceSymbolIdentity Symbol(string symbolId) => + new( + StableIds.ForSemanticC3SourceSymbol(ProjectPath, symbolId), + ProjectPath, + symbolId); + + private static SemanticC3Fact Fact( + SemanticC3SourceSymbolIdentity symbol, + SemanticC3FactKind kind, + string category, + string description, + string? relatedSymbolId) + { + string sourcePath = symbol.SymbolId.StartsWith("M:", StringComparison.Ordinal) + ? ProgramPath + : "src/Ingestion.Api/SemanticFixture.cs"; + + return new SemanticC3Fact( + StableIds.ForSemanticC3Fact( + ProjectPath, + sourcePath, + symbol.Id, + kind, + category, + relatedSymbolId ?? string.Empty), + ProjectPath, + sourcePath, + 1, + symbol, + kind, + category, + description, + relatedSymbolId); + } +} From 3cb53fac8e7795ec3ec090359119101495c8b8bd Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:14:12 -0300 Subject: [PATCH 10/74] docs: define semantic HTTP route aggregation policy (#64) --- docs/c4-mapping.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/c4-mapping.md b/docs/c4-mapping.md index 07bec263..163d8f71 100644 --- a/docs/c4-mapping.md +++ b/docs/c4-mapping.md @@ -123,6 +123,20 @@ The Phase 2 fixtures under `examples/models/` are intentionally review-oriented: All fixture evidence IDs resolve inside the embedded v1 snapshot. Tests deserialize them through `ContractJson`, validate the contract, and assert the negative rules above. +## Semantic C3 HTTP/application policy + +Semantic C3 remains additive to the stable C1/C2 model. For HTTP/application proposals: + +- controller routes are aggregated by the declaring controller type, so multiple actions do not become arbitrary one-route components; +- Minimal API routes are aggregated by their enclosing semantic symbol (for example a top-level host or a route-mapping extension method); +- explicit endpoint handlers, typed endpoint dependencies, controller injection, DI registration and direct static symbol collaboration are structural signals that may support an `applicationService` candidate; +- a class name such as `Service`, `Handler` or `UseCase` is never sufficient by itself; +- a DI-registered type that is merely co-located with a Minimal API host may be proposed only as `requiresReview`, with the missing endpoint-level usage called out explicitly; +- worker, messaging and persistence responsibilities are excluded from this HTTP/application pass and are classified separately; +- internal C3 relations are not created by this step; relation construction has its own provenance/confidence policy. + +Component IDs derive from stable source-symbol identity and category, and evidence references use Semantic C3 fact IDs. This keeps route aggregation and naming deterministic across repeated analysis while preserving the facts that motivated each proposal. + ## Out of scope This policy does not implement LikeC4 emission, LikeC4 validation, CLI commands, MCP transport, AI calls, C3, external services or automatic PR generation. From 253191a88ff2e19c0b704e4cbce53e37e6f6d7a5 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:42:41 -0300 Subject: [PATCH 11/74] feat: capture repository persistence signals (#65) --- .../Inspection/SemanticC3FactExtractor.cs | 24 +++++++++++++++++++ .../SemanticC3FactExtractorTests.cs | 5 ++++ 2 files changed, 29 insertions(+) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index e0014520..38139e15 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -312,6 +312,18 @@ private static void AnalyzeUnit( "dbContext")); } + if (!type.IsInterface && IsRepositoryImplementationSignal(type)) + { + facts.AddObservation(Fact( + unit, + type.Line, + typeSymbol, + SemanticC3FactKind.PersistenceCandidate, + "semantic.persistence.repositoryImplementation", + "Concrete type implements or declares a repository-shaped persistence abstraction.", + "repository")); + } + if (IsMessagingAbstraction(type)) { facts.AddObservation(Fact( @@ -1066,6 +1078,18 @@ private static bool IsController(TypeDeclaration type) => type.BaseTypes.Any(item => SimpleTypeName(item) is "Controller" or "ControllerBase"); + private static bool IsRepositoryImplementationSignal(TypeDeclaration type) + { + if (type.Name.EndsWith("Repository", StringComparison.Ordinal)) + { + return true; + } + + return type.BaseTypes + .Select(SimpleTypeName) + .Any(item => item.EndsWith("Repository", StringComparison.Ordinal)); + } + private static bool IsMessagingAbstraction(TypeDeclaration type) { IEnumerable candidates = type.BaseTypes diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index bdc4dd23..1f60afc8 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -66,6 +66,9 @@ namespace Sample.App; public sealed class OrdersDbContext : DbContext { } + public interface IOrderRepository { } + public sealed class OrderRepository : IOrderRepository { } + public interface IEventPublisher { } public sealed class RabbitPublisher : IEventPublisher { } @@ -85,6 +88,8 @@ public interface IOrderConsumer { } Assert.Contains(result.Facts, fact => fact.Category == "semantic.host.backgroundService"); Assert.Contains(result.Facts, fact => fact.Category == "semantic.wiring.hostedServiceRegistration"); Assert.Contains(result.Facts, fact => fact.Category == "semantic.persistence.dbContext"); + Assert.Contains(result.Facts, fact => fact.Category == "semantic.persistence.repositoryImplementation" + && fact.SourceSymbol.SymbolId.EndsWith(".OrderRepository", StringComparison.Ordinal)); Assert.Contains(result.Facts, fact => fact.Category == "semantic.messaging.abstraction"); Assert.Contains(result.Facts, fact => fact.Category == "semantic.collaboration.staticInvocation" && fact.RelatedSymbolId!.Contains("Helper.Process", StringComparison.Ordinal)); From 22bf863cccafc4df99b1d599d589ccefb261f9f7 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:45:28 -0300 Subject: [PATCH 12/74] feat: propose worker messaging and persistence C3 components (#65) --- .../C3/SemanticC3WorkerIntegrationProposer.cs | 1178 +++++++++++++++++ .../ExternalIntegrationArchitectureMapper.cs | 4 +- 2 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs diff --git a/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs new file mode 100644 index 00000000..e39ca7f4 --- /dev/null +++ b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs @@ -0,0 +1,1178 @@ +using System.Collections.Immutable; +using System.Text; +using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; + +namespace Repo2C4.Core.C3; + +/// +/// Proposes reviewable worker, messaging and persistence Semantic C3 components. +/// Local structural facts establish internal responsibility candidates; imported integration evidence +/// supplies transport/data direction and the already-mapped C1/C2 relation supplies the external peer. +/// +public static class SemanticC3WorkerIntegrationProposer +{ + public static SemanticC3Proposal Propose( + ArchitectureModel baseModel, + string selectedContainerId, + SemanticC3FactSet factSet, + ExternalIntegrationEvidenceResult externalEvidence) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); + ArgumentNullException.ThrowIfNull(factSet); + ArgumentNullException.ThrowIfNull(externalEvidence); + + ValidateInputs(baseModel, factSet, externalEvidence); + + ArchitectureElement? selected = baseModel.Elements.SingleOrDefault(element => + element.Id == selectedContainerId && + element.Kind == ArchitectureElementKind.Container); + if (selected is null) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.containerMissing", + "$.selectedContainerIds", + "Selected Semantic C3 container must exist in the C2 base model."), + ]); + } + + HashSet selectedProjects = ResolveSelectedProjects(baseModel, selected); + SemanticC3Fact[] scopedFacts = + [ + .. factSet.Facts + .Where(fact => selectedProjects.Contains(fact.ProjectPath)) + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + + Dictionary typeFacts = scopedFacts + .Where(fact => fact.Kind == SemanticC3FactKind.TypeDeclaration) + .GroupBy(fact => fact.SourceSymbol.Id, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + + List components = []; + List relations = []; + + WorkerCandidate[] workers = BuildWorkers(selected, scopedFacts, typeFacts); + components.AddRange(workers.Select(worker => worker.Component)); + + MessagingCandidate[] messaging = BuildMessaging( + selected, + scopedFacts, + typeFacts, + selectedProjects, + externalEvidence); + components.AddRange(messaging.Select(candidate => candidate.Component)); + + PersistenceCandidate[] persistence = BuildPersistence( + selected, + scopedFacts, + typeFacts, + workers, + messaging); + components.AddRange(persistence.Select(candidate => candidate.Component)); + + AddMessagingExternalRelations( + baseModel, + selected, + messaging, + relations); + + AddPersistenceExternalRelations( + baseModel, + selected, + workers, + persistence, + selectedProjects, + externalEvidence, + relations); + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [selected.Id], + [ + .. components + .GroupBy(component => component.Id, StringComparer.Ordinal) + .Select(group => MergeComponents(group)) + .OrderBy(component => component.Id, StringComparer.Ordinal), + ], + [ + .. relations + .GroupBy(relation => relation.Id, StringComparer.Ordinal) + .Select(group => MergeRelations(group)) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]); + + ImmutableArray errors = SemanticC3Validator.Validate(proposal); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return proposal; + } + + private static WorkerCandidate[] BuildWorkers( + ArchitectureElement selected, + SemanticC3Fact[] facts, + Dictionary typeFacts) + { + Dictionary workers = new(StringComparer.Ordinal); + + foreach (SemanticC3Fact fact in facts.Where(fact => + fact.Kind == SemanticC3FactKind.HostedService && + fact.Category == "semantic.host.backgroundService")) + { + WorkerAccumulator accumulator = GetWorker(workers, fact.SourceSymbol); + accumulator.DirectBoundary = true; + accumulator.EvidenceIds.Add(fact.Id); + if (typeFacts.TryGetValue(fact.SourceSymbol.Id, out SemanticC3Fact? typeFact)) + { + accumulator.EvidenceIds.Add(typeFact.Id); + } + } + + foreach (SemanticC3Fact registration in facts.Where(fact => + fact.Kind == SemanticC3FactKind.HostedService && + fact.Category == "semantic.wiring.hostedServiceRegistration")) + { + string? hostedType = ParseHostedType(registration.RelatedSymbolId); + if (hostedType is null) + { + continue; + } + + SemanticC3Fact? typeFact = ResolveConcreteType(typeFacts.Values, hostedType); + if (typeFact is null) + { + continue; + } + + WorkerAccumulator accumulator = GetWorker(workers, typeFact.SourceSymbol); + accumulator.RegisteredBoundary = true; + accumulator.EvidenceIds.Add(typeFact.Id); + accumulator.EvidenceIds.Add(registration.Id); + } + + return + [ + .. workers.Values + .Select(worker => + { + string typeName = SimpleTypeNameFromSymbol(worker.SourceSymbol.SymbolId); + string logicalName = WorkerLogicalName(typeName); + SemanticC3ComponentCandidate component = new( + StableIds.ForSemanticC3Component( + selected.Id, + SemanticC3ComponentCategory.BackgroundWorker, + worker.SourceSymbol.Id), + selected.Id, + SemanticC3ComponentCategory.BackgroundWorker, + logicalName, + "Runs a hosted/background processing boundary inside the selected container.", + [.. worker.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + worker.SourceSymbol, + ReviewStatus.RequiresReview, + worker.DirectBoundary + ? "BackgroundService/IHostedService structure supports this worker boundary; its C3 responsibility remains reviewable." + : "Hosted-service registration points to this type, but the implementation boundary is only registration-backed and requires review."); + + return new WorkerCandidate( + component, + worker.SourceSymbol, + worker.DirectBoundary, + worker.RegisteredBoundary); + }) + .OrderBy(worker => worker.Component.Id, StringComparer.Ordinal), + ]; + } + + private static MessagingCandidate[] BuildMessaging( + ArchitectureElement selected, + SemanticC3Fact[] facts, + Dictionary typeFacts, + HashSet selectedProjects, + ExternalIntegrationEvidenceResult externalEvidence) + { + SemanticC3Fact[] localSignals = + [ + .. facts + .Where(fact => + fact.Kind == SemanticC3FactKind.MessagingCandidate && + IsConcreteTypeSignal(fact, typeFacts)) + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + + Dictionary candidates = new(StringComparer.Ordinal); + + foreach (ExternalIntegrationEvidence evidence in externalEvidence.Evidence + .Where(item => + selectedProjects.Contains(item.ProjectPath) && + item.Kind == ExternalIntegrationKind.Messaging && + item.Direction is ExternalIntegrationDirection.Publish or ExternalIntegrationDirection.Consume && + ExternalIntegrationArchitectureMapper.IsSupported(item)) + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + SemanticC3Fact? local = MatchMessagingSignal(localSignals, evidence); + if (local is null) + { + continue; + } + + SemanticC3ComponentCategory category = evidence.Direction == ExternalIntegrationDirection.Publish + ? SemanticC3ComponentCategory.MessagingPublisher + : SemanticC3ComponentCategory.MessagingConsumer; + string stableKey = local.SourceSymbol.Id + "|" + evidence.Technology.ToLowerInvariant(); + string componentId = StableIds.ForSemanticC3Component( + selected.Id, + category, + stableKey); + + if (!candidates.TryGetValue(componentId, out MessagingAccumulator? candidate)) + { + candidate = new MessagingAccumulator( + componentId, + local.SourceSymbol, + category, + evidence.Technology); + candidates.Add(componentId, candidate); + } + + candidate.LocalEvidenceIds.Add(local.Id); + candidate.ExternalEvidence.Add(evidence); + } + + return + [ + .. candidates.Values + .Select(candidate => + { + string provider = ExternalIntegrationArchitectureMapper.MessagingProviderName( + candidate.Technology) ?? Humanize(candidate.Technology); + string directionName = candidate.Category == SemanticC3ComponentCategory.MessagingPublisher + ? "publisher" + : "consumer"; + ExternalIntegrationConfidence weakest = candidate.ExternalEvidence + .Max(item => item.Confidence); + string confidence = weakest.ToString().ToLowerInvariant(); + string reviewReason = + "Local messaging-role evidence and imported " + directionName + + " evidence converge on this adapter; external integration confidence is " + + confidence + " and the C3 boundary remains reviewable."; + + HashSet evidenceIds = new(candidate.LocalEvidenceIds, StringComparer.Ordinal); + foreach (ExternalIntegrationEvidence external in candidate.ExternalEvidence) + { + evidenceIds.Add(external.Id); + } + + SemanticC3ComponentCandidate component = new( + candidate.ComponentId, + selected.Id, + candidate.Category, + Truncate(provider + " " + directionName + " adapter", 120), + candidate.Category == SemanticC3ComponentCategory.MessagingPublisher + ? "Publishes messages from the selected container through the evidenced messaging provider." + : "Consumes messages for the selected container through the evidenced messaging provider.", + [.. evidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + candidate.SourceSymbol, + ReviewStatus.RequiresReview, + reviewReason); + + return new MessagingCandidate( + component, + candidate.SourceSymbol, + candidate.Technology, + [.. candidate.ExternalEvidence.OrderBy(item => item.Id, StringComparer.Ordinal)]); + }) + .OrderBy(candidate => candidate.Component.Id, StringComparer.Ordinal), + ]; + } + + private static PersistenceCandidate[] BuildPersistence( + ArchitectureElement selected, + SemanticC3Fact[] facts, + Dictionary typeFacts, + WorkerCandidate[] workers, + MessagingCandidate[] messaging) + { + Dictionary candidates = new(StringComparer.Ordinal); + + foreach (SemanticC3Fact fact in facts.Where(fact => + fact.Kind == SemanticC3FactKind.PersistenceCandidate && + fact.Category == "semantic.persistence.dbContext")) + { + PersistenceAccumulator candidate = GetPersistence(candidates, fact.SourceSymbol, PersistenceKind.DbContext); + candidate.EvidenceIds.Add(fact.Id); + if (typeFacts.TryGetValue(fact.SourceSymbol.Id, out SemanticC3Fact? typeFact)) + { + candidate.EvidenceIds.Add(typeFact.Id); + } + } + + SemanticC3Fact[] repositoryFacts = + [ + .. facts.Where(fact => + fact.Kind == SemanticC3FactKind.PersistenceCandidate && + fact.Category == "semantic.persistence.repositoryImplementation"), + ]; + + DiRegistration[] registrations = + [ + .. facts + .Where(fact => fact.Kind == SemanticC3FactKind.DependencyInjectionRegistration) + .Select(ParseRegistration) + .Where(item => item is not null) + .Select(item => item!) + .OrderBy(item => item.Fact.Id, StringComparer.Ordinal), + ]; + + HashSet activeBoundaryTypeIds = + [ + .. workers.Select(worker => worker.SourceSymbol.SymbolId), + .. messaging.Select(candidate => candidate.SourceSymbol.SymbolId), + ]; + + foreach (DiRegistration registration in registrations) + { + SemanticC3Fact? repository = ResolvePersistenceFact(repositoryFacts, registration.ImplementationType); + if (repository is null) + { + continue; + } + + SemanticC3Fact? injection = facts.FirstOrDefault(fact => + fact.Kind == SemanticC3FactKind.ConstructorInjection && + activeBoundaryTypeIds.Contains(DeclaringTypeId(fact.SourceSymbol.SymbolId) ?? string.Empty) && + string.Equals( + SimpleTypeName(RelatedTypeName(fact.RelatedSymbolId) ?? string.Empty), + SimpleTypeName(registration.ServiceType), + StringComparison.Ordinal)); + + if (injection is null) + { + continue; + } + + PersistenceAccumulator candidate = GetPersistence( + candidates, + repository.SourceSymbol, + PersistenceKind.Repository); + candidate.EvidenceIds.Add(repository.Id); + candidate.EvidenceIds.Add(registration.Fact.Id); + candidate.EvidenceIds.Add(injection.Id); + if (typeFacts.TryGetValue(repository.SourceSymbol.Id, out SemanticC3Fact? typeFact)) + { + candidate.EvidenceIds.Add(typeFact.Id); + } + } + + foreach (SemanticC3Fact repository in repositoryFacts) + { + string repositoryType = repository.SourceSymbol.SymbolId.StartsWith("T:", StringComparison.Ordinal) + ? repository.SourceSymbol.SymbolId[2..] + : repository.SourceSymbol.SymbolId; + + SemanticC3Fact? invocation = facts.FirstOrDefault(fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation && + activeBoundaryTypeIds.Contains(DeclaringTypeId(fact.SourceSymbol.SymbolId) ?? string.Empty) && + string.Equals( + RelatedMethodTypeName(fact.RelatedSymbolId), + repositoryType, + StringComparison.Ordinal)); + + if (invocation is null) + { + continue; + } + + PersistenceAccumulator candidate = GetPersistence( + candidates, + repository.SourceSymbol, + PersistenceKind.Repository); + candidate.EvidenceIds.Add(repository.Id); + candidate.EvidenceIds.Add(invocation.Id); + if (typeFacts.TryGetValue(repository.SourceSymbol.Id, out SemanticC3Fact? typeFact)) + { + candidate.EvidenceIds.Add(typeFact.Id); + } + } + + return + [ + .. candidates.Values + .Select(candidate => + { + string typeName = SimpleTypeNameFromSymbol(candidate.SourceSymbol.SymbolId); + string name = candidate.Kind == PersistenceKind.DbContext + ? DbContextName(typeName) + : RepositoryName(typeName); + string responsibility = candidate.Kind == PersistenceKind.DbContext + ? "Provides the EF Core persistence/transaction boundary observed in the selected container." + : "Implements a repository persistence boundary that is observably used by a worker or messaging adapter."; + string reason = candidate.Kind == PersistenceKind.DbContext + ? "DbContext inheritance is a strong local persistence signal, but the architectural component boundary remains reviewable." + : "Repository shape is combined with DI/injection or direct collaboration before proposing persistence; the component boundary remains reviewable."; + + SemanticC3ComponentCandidate component = new( + StableIds.ForSemanticC3Component( + selected.Id, + SemanticC3ComponentCategory.PersistenceAdapter, + candidate.SourceSymbol.Id), + selected.Id, + SemanticC3ComponentCategory.PersistenceAdapter, + name, + responsibility, + [.. candidate.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + candidate.SourceSymbol, + ReviewStatus.RequiresReview, + reason); + + return new PersistenceCandidate(component, candidate.SourceSymbol, candidate.Kind); + }) + .OrderBy(candidate => candidate.Component.Id, StringComparer.Ordinal), + ]; + } + + private static void AddMessagingExternalRelations( + ArchitectureModel baseModel, + ArchitectureElement selected, + MessagingCandidate[] messaging, + List relations) + { + foreach (MessagingCandidate candidate in messaging) + { + foreach (ExternalIntegrationEvidence evidence in candidate.ExternalEvidence) + { + ArchitectureRelation? mapped = FindMappedExternalRelation( + baseModel, + selected.Id, + evidence.Id); + if (mapped is null) + { + continue; + } + + string? externalElementId = OtherEndpoint(mapped, selected.Id); + if (externalElementId is null) + { + continue; + } + + HashSet evidenceIds = new(candidate.Component.EvidenceIds, StringComparer.Ordinal) + { + evidence.Id, + }; + + relations.Add(new SemanticC3RelationCandidate( + StableIds.ForRelation( + candidate.Component.Id, + externalElementId, + "semantic-c3|external|" + evidence.Id), + candidate.Component.Id, + externalElementId, + SemanticC3RelationTargetKind.ArchitectureElement, + mapped.Description, + [.. evidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + ReviewStatus.RequiresReview, + "The existing C1/C2 external relation is associated with this local messaging adapter; runtime flow remains reviewable at C3.")); + } + } + } + + private static void AddPersistenceExternalRelations( + ArchitectureModel baseModel, + ArchitectureElement selected, + WorkerCandidate[] workers, + PersistenceCandidate[] persistence, + HashSet selectedProjects, + ExternalIntegrationEvidenceResult externalEvidence, + List relations) + { + ExternalIntegrationEvidence[] dataEvidence = + [ + .. externalEvidence.Evidence + .Where(item => + selectedProjects.Contains(item.ProjectPath) && + item.Kind == ExternalIntegrationKind.Database) + .OrderBy(item => item.Id, StringComparer.Ordinal), + ]; + + foreach (ExternalIntegrationEvidence evidence in dataEvidence) + { + ArchitectureRelation? mapped = FindMappedExternalRelation( + baseModel, + selected.Id, + evidence.Id); + if (mapped is null) + { + continue; + } + + string? externalElementId = OtherEndpoint(mapped, selected.Id); + if (externalElementId is null) + { + continue; + } + + PersistenceCandidate? persistenceCandidate = MatchPersistenceCandidate( + persistence, + evidence); + + SemanticC3ComponentCandidate? source = persistenceCandidate?.Component; + if (source is null) + { + WorkerCandidate[] exactWorkers = + [ + .. workers.Where(worker => + string.Equals( + WorkerSourcePath(worker, baseModel, evidence.ProjectPath), + evidence.SourcePath, + StringComparison.Ordinal)), + ]; + if (exactWorkers.Length == 1) + { + source = exactWorkers[0].Component; + } + } + + if (source is null) + { + continue; + } + + HashSet evidenceIds = new(source.EvidenceIds, StringComparer.Ordinal) + { + evidence.Id, + }; + + relations.Add(new SemanticC3RelationCandidate( + StableIds.ForRelation( + source.Id, + externalElementId, + "semantic-c3|external|" + evidence.Id), + source.Id, + externalElementId, + SemanticC3RelationTargetKind.ArchitectureElement, + mapped.Description, + [.. evidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + ReviewStatus.RequiresReview, + evidence.Confidence == ExternalIntegrationConfidence.High + ? "The existing C1/C2 database relation is associated with this local persistence responsibility; runtime flow remains reviewable at C3." + : "The existing C1/C2 database relation has " + + evidence.Confidence.ToString().ToLowerInvariant() + + " confidence and remains reviewable when associated with this local persistence responsibility.")); + } + } + + private static PersistenceCandidate? MatchPersistenceCandidate( + PersistenceCandidate[] persistence, + ExternalIntegrationEvidence evidence) + { + PersistenceCandidate[] samePath = + [ + .. persistence.Where(candidate => + string.Equals( + SourcePathFor(candidate.Component, candidate.SourceSymbol, evidence.ProjectPath), + evidence.SourcePath, + StringComparison.Ordinal)), + ]; + if (samePath.Length == 1) + { + return samePath[0]; + } + + PersistenceCandidate[] sameProject = + [ + .. persistence.Where(candidate => + candidate.SourceSymbol.ProjectPath == evidence.ProjectPath), + ]; + return sameProject.Length == 1 ? sameProject[0] : null; + } + + private static string? WorkerSourcePath( + WorkerCandidate worker, + ArchitectureModel baseModel, + string projectPath) + { + _ = baseModel; + _ = projectPath; + return null; + } + + private static string? SourcePathFor( + SemanticC3ComponentCandidate component, + SemanticC3SourceSymbolIdentity symbol, + string projectPath) + { + _ = component; + _ = symbol; + _ = projectPath; + return null; + } + + private static SemanticC3Fact? MatchMessagingSignal( + SemanticC3Fact[] localSignals, + ExternalIntegrationEvidence evidence) + { + MessagingRole expected = evidence.Direction == ExternalIntegrationDirection.Publish + ? MessagingRole.Publisher + : MessagingRole.Consumer; + + SemanticC3Fact[] samePath = + [ + .. localSignals.Where(fact => + fact.ProjectPath == evidence.ProjectPath && + fact.SourcePath == evidence.SourcePath && + RoleOf(fact.SourceSymbol) is var role && + (role == expected || role == MessagingRole.Ambiguous)), + ]; + if (samePath.Length == 1) + { + return samePath[0]; + } + + SemanticC3Fact[] projectRole = + [ + .. localSignals.Where(fact => + fact.ProjectPath == evidence.ProjectPath && + RoleOf(fact.SourceSymbol) == expected), + ]; + return projectRole.Length == 1 ? projectRole[0] : null; + } + + private static MessagingRole RoleOf(SemanticC3SourceSymbolIdentity symbol) + { + string name = SimpleTypeNameFromSymbol(symbol.SymbolId); + bool publisher = + name.Contains("Publisher", StringComparison.OrdinalIgnoreCase) || + name.Contains("Producer", StringComparison.OrdinalIgnoreCase); + bool consumer = + name.Contains("Consumer", StringComparison.OrdinalIgnoreCase) || + name.Contains("Subscriber", StringComparison.OrdinalIgnoreCase); + + return (publisher, consumer) switch + { + (true, false) => MessagingRole.Publisher, + (false, true) => MessagingRole.Consumer, + _ => MessagingRole.Ambiguous, + }; + } + + private static bool IsConcreteTypeSignal( + SemanticC3Fact fact, + Dictionary typeFacts) => + typeFacts.TryGetValue(fact.SourceSymbol.Id, out SemanticC3Fact? typeFact) && + !typeFact.Description.StartsWith("Declares interface", StringComparison.Ordinal); + + private static ArchitectureRelation? FindMappedExternalRelation( + ArchitectureModel model, + string selectedContainerId, + string evidenceId) + { + ArchitectureRelation[] matches = + [ + .. model.Relations.Where(relation => + relation.EvidenceIds.Contains(evidenceId, StringComparer.Ordinal) && + (relation.SourceId == selectedContainerId || + relation.DestinationId == selectedContainerId)), + ]; + + return matches.Length == 1 ? matches[0] : null; + } + + private static string? OtherEndpoint(ArchitectureRelation relation, string selectedContainerId) + { + if (relation.SourceId == selectedContainerId) + { + return relation.DestinationId; + } + + return relation.DestinationId == selectedContainerId + ? relation.SourceId + : null; + } + + private static WorkerAccumulator GetWorker( + Dictionary workers, + SemanticC3SourceSymbolIdentity sourceSymbol) + { + if (!workers.TryGetValue(sourceSymbol.Id, out WorkerAccumulator? worker)) + { + worker = new WorkerAccumulator(sourceSymbol); + workers.Add(sourceSymbol.Id, worker); + } + + return worker; + } + + private static PersistenceAccumulator GetPersistence( + Dictionary candidates, + SemanticC3SourceSymbolIdentity sourceSymbol, + PersistenceKind kind) + { + if (!candidates.TryGetValue(sourceSymbol.Id, out PersistenceAccumulator? candidate)) + { + candidate = new PersistenceAccumulator(sourceSymbol, kind); + candidates.Add(sourceSymbol.Id, candidate); + } + + return candidate; + } + + private static string? ParseHostedType(string? related) + { + if (related is null || !related.StartsWith("HOST:", StringComparison.Ordinal)) + { + return null; + } + + int last = related.LastIndexOf(':'); + return last <= "HOST:".Length + ? null + : related["HOST:".Length..last]; + } + + private static DiRegistration? ParseRegistration(SemanticC3Fact fact) + { + string? related = fact.RelatedSymbolId; + if (related is null || !related.StartsWith("DI:", StringComparison.Ordinal)) + { + return null; + } + + int lifetimeSeparator = related.IndexOf(':', 3); + int arrow = related.IndexOf("->", StringComparison.Ordinal); + int ordinalSeparator = related.LastIndexOf(':'); + + if (lifetimeSeparator < 0 || + arrow <= lifetimeSeparator || + ordinalSeparator <= arrow + 2) + { + return null; + } + + string service = related[(lifetimeSeparator + 1)..arrow]; + string implementation = related[(arrow + 2)..ordinalSeparator]; + + return string.IsNullOrWhiteSpace(service) || string.IsNullOrWhiteSpace(implementation) + ? null + : new DiRegistration(service, implementation, fact); + } + + private static SemanticC3Fact? ResolveConcreteType( + IEnumerable typeFacts, + string typeName) + { + string normalized = NormalizeTypeName(typeName); + string simple = SimpleTypeName(normalized); + SemanticC3Fact[] candidates = + [ + .. typeFacts.Where(fact => + !fact.Description.StartsWith("Declares interface", StringComparison.Ordinal) && + (fact.SourceSymbol.SymbolId == "T:" + normalized || + SimpleTypeNameFromSymbol(fact.SourceSymbol.SymbolId) + .Equals(simple, StringComparison.Ordinal))), + ]; + + SemanticC3Fact[] exact = + [ + .. candidates.Where(fact => fact.SourceSymbol.SymbolId == "T:" + normalized), + ]; + + if (exact.Length == 1) + { + return exact[0]; + } + + return candidates.Length == 1 ? candidates[0] : null; + } + + private static SemanticC3Fact? ResolvePersistenceFact( + IEnumerable persistenceFacts, + string typeName) + { + string normalized = NormalizeTypeName(typeName); + string simple = SimpleTypeName(normalized); + SemanticC3Fact[] candidates = + [ + .. persistenceFacts.Where(fact => + fact.SourceSymbol.SymbolId == "T:" + normalized || + SimpleTypeNameFromSymbol(fact.SourceSymbol.SymbolId) + .Equals(simple, StringComparison.Ordinal)), + ]; + + SemanticC3Fact[] exact = + [ + .. candidates.Where(fact => fact.SourceSymbol.SymbolId == "T:" + normalized), + ]; + + if (exact.Length == 1) + { + return exact[0]; + } + + return candidates.Length == 1 ? candidates[0] : null; + } + + private static string? RelatedTypeName(string? relatedSymbolId) + { + if (relatedSymbolId is null || + !relatedSymbolId.StartsWith("T:", StringComparison.Ordinal)) + { + return null; + } + + int ordinal = relatedSymbolId.LastIndexOf(':'); + return ordinal <= 2 ? null : relatedSymbolId[2..ordinal]; + } + + private static string? RelatedMethodTypeName(string? relatedSymbolId) + { + if (relatedSymbolId is null || + !relatedSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + string target = relatedSymbolId[2..]; + int method = target.LastIndexOf('.'); + return method <= 0 ? null : target[..method]; + } + + private static string? DeclaringTypeId(string methodSymbolId) + { + if (!methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + int parameters = methodSymbolId.IndexOf('('); + string head = parameters >= 0 + ? methodSymbolId[..parameters] + : methodSymbolId; + int separator = head.LastIndexOf('.'); + return separator <= 2 ? null : "T:" + head[2..separator]; + } + + private static HashSet ResolveSelectedProjects( + ArchitectureModel model, + ArchitectureElement selected) + { + Dictionary evidenceById = model.Snapshot.Evidence + .ToDictionary(evidence => evidence.Id, StringComparer.Ordinal); + string[] projectPaths = + [ + .. model.Snapshot.Files + .Select(file => file.RelativePath) + .Where(path => Path.GetExtension(path) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal), + ]; + + HashSet selectedProjects = new(StringComparer.Ordinal); + foreach (string evidenceId in selected.EvidenceIds) + { + if (!evidenceById.TryGetValue(evidenceId, out Evidence? evidence)) + { + continue; + } + + if (Path.GetExtension(evidence.RelativePath) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + { + selectedProjects.Add(evidence.RelativePath); + continue; + } + + string? nearest = projectPaths + .Where(project => IsWithinProject(evidence.RelativePath, project)) + .OrderByDescending(ProjectDepth) + .ThenBy(project => project, StringComparer.Ordinal) + .FirstOrDefault(); + if (nearest is not null) + { + selectedProjects.Add(nearest); + } + } + + return selectedProjects; + } + + private static bool IsWithinProject(string sourcePath, string projectPath) + { + string directory = DirectoryPart(projectPath); + return directory.Length == 0 || + sourcePath.StartsWith(directory + "/", StringComparison.Ordinal) || + sourcePath.Equals(directory, StringComparison.Ordinal); + } + + private static int ProjectDepth(string projectPath) => + DirectoryPart(projectPath).Count(character => character == '/') + 1; + + private static string DirectoryPart(string path) + { + int separator = path.LastIndexOf('/'); + return separator < 0 ? string.Empty : path[..separator]; + } + + private static SemanticC3ComponentCandidate MergeComponents( + IGrouping group) + { + SemanticC3ComponentCandidate first = group.First(); + return first with + { + EvidenceIds = + [ + .. group.SelectMany(component => component.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + }; + } + + private static SemanticC3RelationCandidate MergeRelations( + IGrouping group) + { + SemanticC3RelationCandidate first = group.First(); + return first with + { + EvidenceIds = + [ + .. group.SelectMany(relation => relation.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + }; + } + + private static string WorkerLogicalName(string typeName) + { + string value = TrimSuffix(typeName, "BackgroundService"); + value = TrimSuffix(value, "HostedService"); + value = TrimSuffix(value, "Worker"); + + if (value.EndsWith("Poller", StringComparison.Ordinal)) + { + return Truncate(Humanize(value), 120); + } + + string humanized = Humanize(value); + return Truncate( + (humanized.Length == 0 ? "Background" : humanized) + " worker", + 120); + } + + private static string DbContextName(string typeName) + { + string baseName = TrimSuffix(typeName, "DbContext"); + baseName = TrimSuffix(baseName, "Context"); + string humanized = Humanize(baseName); + return Truncate( + (humanized.Length == 0 ? "Database" : humanized) + " persistence", + 120); + } + + private static string RepositoryName(string typeName) + { + string baseName = TrimSuffix(typeName, "Repository"); + string humanized = Humanize(baseName); + return Truncate( + (humanized.Length == 0 ? "Repository" : humanized + " repository") + + " persistence", + 120); + } + + private static string SimpleTypeNameFromSymbol(string symbolId) => + symbolId.StartsWith("T:", StringComparison.Ordinal) + ? SimpleTypeName(symbolId[2..]) + : SimpleTypeName(symbolId); + + private static string NormalizeTypeName(string value) + { + string normalized = value.Trim() + .Replace("global::", string.Empty, StringComparison.Ordinal) + .TrimEnd('?'); + int generic = normalized.IndexOf('<'); + if (generic >= 0) + { + normalized = normalized[..generic]; + } + + while (normalized.EndsWith("[]", StringComparison.Ordinal)) + { + normalized = normalized[..^2]; + } + + return normalized; + } + + private static string SimpleTypeName(string value) + { + string normalized = NormalizeTypeName(value); + int separator = normalized.LastIndexOf('.'); + return separator >= 0 ? normalized[(separator + 1)..] : normalized; + } + + private static string TrimSuffix(string value, string suffix) => + value.EndsWith(suffix, StringComparison.Ordinal) && + value.Length > suffix.Length + ? value[..^suffix.Length] + : value; + + private static string Humanize(string value) + { + StringBuilder builder = new(); + char previous = '\0'; + + foreach (char character in value) + { + if (!char.IsLetterOrDigit(character)) + { + AppendSpace(builder); + previous = character; + continue; + } + + if (char.IsUpper(character) && + builder.Length > 0 && + (char.IsLower(previous) || char.IsDigit(previous))) + { + AppendSpace(builder); + } + + builder.Append(character); + previous = character; + } + + return builder.ToString().Trim(); + } + + private static void AppendSpace(StringBuilder builder) + { + if (builder.Length > 0 && builder[^1] != ' ') + { + builder.Append(' '); + } + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength + ? value + : value[..maxLength].TrimEnd(); + + private static void ValidateInputs( + ArchitectureModel baseModel, + SemanticC3FactSet factSet, + ExternalIntegrationEvidenceResult externalEvidence) + { + ImmutableArray baseErrors = ContractValidator.ValidateModel(baseModel); + if (!baseErrors.IsEmpty) + { + throw new ContractValidationException(baseErrors); + } + + if (factSet.SchemaVersion != SemanticC3ContractSchema.Version || + factSet.Facts.IsDefault || + factSet.Diagnostics.IsDefault) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.factsInvalid", + "$.facts", + "Semantic C3 fact set must use the supported schema and initialized collections."), + ]); + } + + if (externalEvidence.Evidence.IsDefault || + externalEvidence.Diagnostics.IsDefault) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.externalEvidenceInvalid", + "$.externalEvidence", + "External integration evidence must use initialized collections."), + ]); + } + } + + private enum MessagingRole + { + Ambiguous, + Publisher, + Consumer, + } + + private enum PersistenceKind + { + DbContext, + Repository, + } + + private sealed class WorkerAccumulator(SemanticC3SourceSymbolIdentity sourceSymbol) + { + public SemanticC3SourceSymbolIdentity SourceSymbol { get; } = sourceSymbol; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public bool DirectBoundary { get; set; } + + public bool RegisteredBoundary { get; set; } + } + + private sealed record WorkerCandidate( + SemanticC3ComponentCandidate Component, + SemanticC3SourceSymbolIdentity SourceSymbol, + bool DirectBoundary, + bool RegisteredBoundary); + + private sealed class MessagingAccumulator( + string componentId, + SemanticC3SourceSymbolIdentity sourceSymbol, + SemanticC3ComponentCategory category, + string technology) + { + public string ComponentId { get; } = componentId; + + public SemanticC3SourceSymbolIdentity SourceSymbol { get; } = sourceSymbol; + + public SemanticC3ComponentCategory Category { get; } = category; + + public string Technology { get; } = technology; + + public HashSet LocalEvidenceIds { get; } = new(StringComparer.Ordinal); + + public List ExternalEvidence { get; } = []; + } + + private sealed record MessagingCandidate( + SemanticC3ComponentCandidate Component, + SemanticC3SourceSymbolIdentity SourceSymbol, + string Technology, + ImmutableArray ExternalEvidence); + + private sealed class PersistenceAccumulator( + SemanticC3SourceSymbolIdentity sourceSymbol, + PersistenceKind kind) + { + public SemanticC3SourceSymbolIdentity SourceSymbol { get; } = sourceSymbol; + + public PersistenceKind Kind { get; } = kind; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + } + + private sealed record PersistenceCandidate( + SemanticC3ComponentCandidate Component, + SemanticC3SourceSymbolIdentity SourceSymbol, + PersistenceKind Kind); + + private sealed record DiRegistration( + string ServiceType, + string ImplementationType, + SemanticC3Fact Fact); +} diff --git a/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationArchitectureMapper.cs b/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationArchitectureMapper.cs index be09cee6..fb4f46c6 100644 --- a/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationArchitectureMapper.cs +++ b/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationArchitectureMapper.cs @@ -313,7 +313,7 @@ private static string ResourcePhrase(ExternalIntegrationEvidence evidence) => _ => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(technology.Replace('-', ' ')), }; - private static bool IsSupported(ExternalIntegrationEvidence evidence) => evidence.Kind switch + internal static bool IsSupported(ExternalIntegrationEvidence evidence) => evidence.Kind switch { ExternalIntegrationKind.Http => evidence.Direction == ExternalIntegrationDirection.Outbound, ExternalIntegrationKind.Messaging => @@ -334,7 +334,7 @@ evidence.Direction is ExternalIntegrationDirection.Read or ExternalIntegrationDi _ => false, }; - private static string? MessagingProviderName(string technology) => technology.ToLowerInvariant() switch + internal static string? MessagingProviderName(string technology) => technology.ToLowerInvariant() switch { "aws-eventbridge" => "Amazon EventBridge", "aws-kinesis" => "Amazon Kinesis", From 497f5da988b3e9400928fc4f2c6439c85c1d8c63 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:46:11 -0300 Subject: [PATCH 13/74] fix: correlate worker persistence by local source path (#65) --- .../C3/SemanticC3WorkerIntegrationProposer.cs | 53 ++++++++----------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs index e39ca7f4..dc9742f2 100644 --- a/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs +++ b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs @@ -128,6 +128,7 @@ private static WorkerCandidate[] BuildWorkers( WorkerAccumulator accumulator = GetWorker(workers, fact.SourceSymbol); accumulator.DirectBoundary = true; accumulator.EvidenceIds.Add(fact.Id); + accumulator.SourcePaths.Add(fact.SourcePath); if (typeFacts.TryGetValue(fact.SourceSymbol.Id, out SemanticC3Fact? typeFact)) { accumulator.EvidenceIds.Add(typeFact.Id); @@ -154,6 +155,8 @@ private static WorkerCandidate[] BuildWorkers( accumulator.RegisteredBoundary = true; accumulator.EvidenceIds.Add(typeFact.Id); accumulator.EvidenceIds.Add(registration.Id); + accumulator.SourcePaths.Add(typeFact.SourcePath); + accumulator.SourcePaths.Add(registration.SourcePath); } return @@ -182,6 +185,7 @@ [.. worker.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], return new WorkerCandidate( component, worker.SourceSymbol, + [.. worker.SourcePaths.OrderBy(path => path, StringComparer.Ordinal)], worker.DirectBoundary, worker.RegisteredBoundary); }) @@ -306,6 +310,7 @@ private static PersistenceCandidate[] BuildPersistence( { PersistenceAccumulator candidate = GetPersistence(candidates, fact.SourceSymbol, PersistenceKind.DbContext); candidate.EvidenceIds.Add(fact.Id); + candidate.SourcePaths.Add(fact.SourcePath); if (typeFacts.TryGetValue(fact.SourceSymbol.Id, out SemanticC3Fact? typeFact)) { candidate.EvidenceIds.Add(typeFact.Id); @@ -363,6 +368,9 @@ .. messaging.Select(candidate => candidate.SourceSymbol.SymbolId), candidate.EvidenceIds.Add(repository.Id); candidate.EvidenceIds.Add(registration.Fact.Id); candidate.EvidenceIds.Add(injection.Id); + candidate.SourcePaths.Add(repository.SourcePath); + candidate.SourcePaths.Add(registration.Fact.SourcePath); + candidate.SourcePaths.Add(injection.SourcePath); if (typeFacts.TryGetValue(repository.SourceSymbol.Id, out SemanticC3Fact? typeFact)) { candidate.EvidenceIds.Add(typeFact.Id); @@ -394,6 +402,8 @@ .. messaging.Select(candidate => candidate.SourceSymbol.SymbolId), PersistenceKind.Repository); candidate.EvidenceIds.Add(repository.Id); candidate.EvidenceIds.Add(invocation.Id); + candidate.SourcePaths.Add(repository.SourcePath); + candidate.SourcePaths.Add(invocation.SourcePath); if (typeFacts.TryGetValue(repository.SourceSymbol.Id, out SemanticC3Fact? typeFact)) { candidate.EvidenceIds.Add(typeFact.Id); @@ -430,7 +440,11 @@ [.. candidate.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], ReviewStatus.RequiresReview, reason); - return new PersistenceCandidate(component, candidate.SourceSymbol, candidate.Kind); + return new PersistenceCandidate( + component, + candidate.SourceSymbol, + [.. candidate.SourcePaths.OrderBy(path => path, StringComparer.Ordinal)], + candidate.Kind); }) .OrderBy(candidate => candidate.Component.Id, StringComparer.Ordinal), ]; @@ -527,10 +541,7 @@ .. externalEvidence.Evidence WorkerCandidate[] exactWorkers = [ .. workers.Where(worker => - string.Equals( - WorkerSourcePath(worker, baseModel, evidence.ProjectPath), - evidence.SourcePath, - StringComparison.Ordinal)), + worker.SourcePaths.Contains(evidence.SourcePath, StringComparer.Ordinal)), ]; if (exactWorkers.Length == 1) { @@ -574,10 +585,7 @@ [.. evidenceIds.OrderBy(id => id, StringComparer.Ordinal)], PersistenceCandidate[] samePath = [ .. persistence.Where(candidate => - string.Equals( - SourcePathFor(candidate.Component, candidate.SourceSymbol, evidence.ProjectPath), - evidence.SourcePath, - StringComparison.Ordinal)), + candidate.SourcePaths.Contains(evidence.SourcePath, StringComparer.Ordinal)), ]; if (samePath.Length == 1) { @@ -592,27 +600,6 @@ .. persistence.Where(candidate => return sameProject.Length == 1 ? sameProject[0] : null; } - private static string? WorkerSourcePath( - WorkerCandidate worker, - ArchitectureModel baseModel, - string projectPath) - { - _ = baseModel; - _ = projectPath; - return null; - } - - private static string? SourcePathFor( - SemanticC3ComponentCandidate component, - SemanticC3SourceSymbolIdentity symbol, - string projectPath) - { - _ = component; - _ = symbol; - _ = projectPath; - return null; - } - private static SemanticC3Fact? MatchMessagingSignal( SemanticC3Fact[] localSignals, ExternalIntegrationEvidence evidence) @@ -1119,6 +1106,8 @@ private sealed class WorkerAccumulator(SemanticC3SourceSymbolIdentity sourceSymb public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + public HashSet SourcePaths { get; } = new(StringComparer.Ordinal); + public bool DirectBoundary { get; set; } public bool RegisteredBoundary { get; set; } @@ -1127,6 +1116,7 @@ private sealed class WorkerAccumulator(SemanticC3SourceSymbolIdentity sourceSymb private sealed record WorkerCandidate( SemanticC3ComponentCandidate Component, SemanticC3SourceSymbolIdentity SourceSymbol, + ImmutableArray SourcePaths, bool DirectBoundary, bool RegisteredBoundary); @@ -1164,11 +1154,14 @@ private sealed class PersistenceAccumulator( public PersistenceKind Kind { get; } = kind; public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public HashSet SourcePaths { get; } = new(StringComparer.Ordinal); } private sealed record PersistenceCandidate( SemanticC3ComponentCandidate Component, SemanticC3SourceSymbolIdentity SourceSymbol, + ImmutableArray SourcePaths, PersistenceKind Kind); private sealed record DiRegistration( From b5c6fac1e51864ee245ba318cc1ff8ab679c1fb2 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:47:33 -0300 Subject: [PATCH 14/74] test: cover semantic worker integration proposals (#65) --- ...emanticC3WorkerIntegrationProposerTests.cs | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3WorkerIntegrationProposerTests.cs diff --git a/tests/Repo2C4.Core.Tests/SemanticC3WorkerIntegrationProposerTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3WorkerIntegrationProposerTests.cs new file mode 100644 index 00000000..874b4e19 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3WorkerIntegrationProposerTests.cs @@ -0,0 +1,565 @@ +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3WorkerIntegrationProposerTests +{ + private const string ProjectPath = "src/Worker/Worker.csproj"; + private const string ContainerId = "el_worker"; + + [Fact] + public void OutboxWorkerPublisherAndDbContextProduceBoundedComponentsAndExternalRelations() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity worker = Symbol("T:Sample.OutboxWorker"); + SemanticC3SourceSymbolIdentity publisher = Symbol("T:Sample.RabbitPublisher"); + SemanticC3SourceSymbolIdentity dbContext = Symbol("T:Sample.OutboxDbContext"); + + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OutboxWorker.", null), + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.HostedService, + "semantic.host.backgroundService", "Background worker boundary.", "hostedService"), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol RabbitPublisher.", null), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Publisher role signal.", "messaging"), + Fact("src/Worker/Data.cs", dbContext, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OutboxDbContext.", null), + Fact("src/Worker/Data.cs", dbContext, SemanticC3FactKind.PersistenceCandidate, + "semantic.persistence.dbContext", "DbContext persistence boundary.", "dbContext")); + + ExternalIntegrationEvidenceResult external = ExternalResult( + External( + "ev_publish", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Publish, + "rabbitmq", + "orders.exchange", + "exchange", + "OrderCreated", + "src/Worker/Publisher.cs", + ExternalIntegrationConfidence.High), + External( + "ev_database", + ExternalIntegrationKind.Database, + ExternalIntegrationDirection.Read, + "postgresql", + "OutboxDb", + "database", + null, + "src/Worker/Data.cs", + ExternalIntegrationConfidence.High)); + + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + Assert.Contains(proposal.Components, component => + component.Category == SemanticC3ComponentCategory.BackgroundWorker && + component.Name == "Outbox worker"); + Assert.Contains(proposal.Components, component => + component.Category == SemanticC3ComponentCategory.MessagingPublisher && + component.Name == "RabbitMQ publisher adapter"); + Assert.Contains(proposal.Components, component => + component.Category == SemanticC3ComponentCategory.PersistenceAdapter && + component.Name == "Outbox persistence"); + + Assert.Contains(proposal.Relations, relation => + relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement && + relation.Description == "Publishes OrderCreated to exchange orders.exchange"); + Assert.Contains(proposal.Relations, relation => + relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement && + relation.Description == "Uses PostgreSQL database OutboxDb"); + Assert.All(proposal.Relations, relation => + Assert.Equal(ReviewStatus.RequiresReview, relation.Status)); + } + + [Fact] + public void ConsumerEvidenceProducesConsumerAdapterWithoutInventingRemotePeer() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity consumer = Symbol("T:Sample.OrderConsumer"); + + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Consumer.cs", consumer, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderConsumer.", null), + Fact("src/Worker/Consumer.cs", consumer, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Consumer role signal.", "messaging")); + + ExternalIntegrationEvidenceResult external = ExternalResult( + External( + "ev_consume", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Consume, + "azure-servicebus", + "orders", + "queue", + "OrderCreated", + "src/Worker/Consumer.cs", + ExternalIntegrationConfidence.High)); + + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + SemanticC3ComponentCandidate consumerComponent = Assert.Single(proposal.Components); + Assert.Equal(SemanticC3ComponentCategory.MessagingConsumer, consumerComponent.Category); + Assert.Equal("Azure Service Bus consumer adapter", consumerComponent.Name); + + SemanticC3RelationCandidate relation = Assert.Single(proposal.Relations); + Assert.Equal("Consumes OrderCreated from queue orders", relation.Description); + Assert.Contains( + mapped.Elements, + element => element.Id == relation.DestinationId && + element.Name == "Azure Service Bus"); + Assert.DoesNotContain(mapped.Elements, element => element.Name == "OrderCreated"); + } + + [Fact] + public void RepositoryRequiresObservedUseBeforeBecomingPersistenceComponent() + { + ArchitectureModel model = BaseModel(); + SemanticC3SourceSymbolIdentity worker = Symbol("T:Sample.OutboxWorker"); + SemanticC3SourceSymbolIdentity constructor = Symbol( + "M:Sample.OutboxWorker.#ctor(IOrderRepository)"); + SemanticC3SourceSymbolIdentity repository = Symbol("T:Sample.OrderRepository"); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + + SemanticC3FactSet usedFacts = FactSet( + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OutboxWorker.", null), + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.HostedService, + "semantic.host.backgroundService", "Background worker boundary.", "hostedService"), + Fact("src/Worker/Worker.cs", constructor, SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", "Worker injects repository.", "T:IOrderRepository:0"), + Fact("src/Worker/Program.cs", program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", "DI repository registration.", + "DI:Scoped:IOrderRepository->OrderRepository:0"), + Fact("src/Worker/Repository.cs", repository, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderRepository.", null), + Fact("src/Worker/Repository.cs", repository, SemanticC3FactKind.PersistenceCandidate, + "semantic.persistence.repositoryImplementation", "Repository implementation signal.", "repository")); + + SemanticC3Proposal used = SemanticC3WorkerIntegrationProposer.Propose( + model, + ContainerId, + usedFacts, + ExternalResult()); + + Assert.Contains(used.Components, component => + component.Category == SemanticC3ComponentCategory.PersistenceAdapter && + component.Name == "Order repository persistence"); + + SemanticC3FactSet unusedFacts = FactSet( + Fact("src/Worker/Repository.cs", repository, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderRepository.", null), + Fact("src/Worker/Repository.cs", repository, SemanticC3FactKind.PersistenceCandidate, + "semantic.persistence.repositoryImplementation", "Repository implementation signal.", "repository")); + + SemanticC3Proposal unused = SemanticC3WorkerIntegrationProposer.Propose( + model, + ContainerId, + unusedFacts, + ExternalResult()); + + Assert.DoesNotContain(unused.Components, component => + component.Category == SemanticC3ComponentCategory.PersistenceAdapter); + } + + [Fact] + public void HostedRegistrationCanProduceReviewableWorkerBoundary() + { + ArchitectureModel model = BaseModel(); + SemanticC3SourceSymbolIdentity worker = Symbol("T:Sample.PollingWorker"); + SemanticC3SourceSymbolIdentity program = Symbol("M:.Program.()"); + + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol PollingWorker.", null), + Fact("src/Worker/Program.cs", program, SemanticC3FactKind.HostedService, + "semantic.wiring.hostedServiceRegistration", "Hosted service registration.", + "HOST:PollingWorker:0")); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + model, + ContainerId, + facts, + ExternalResult()); + + SemanticC3ComponentCandidate component = Assert.Single(proposal.Components); + Assert.Equal(SemanticC3ComponentCategory.BackgroundWorker, component.Category); + Assert.Equal(ReviewStatus.RequiresReview, component.Status); + Assert.Contains("registration-backed", component.ReviewReason!, StringComparison.Ordinal); + } + + [Fact] + public void FrameworkOnlyMessagingWithoutTransportDoesNotCreateAdapter() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity publisher = Symbol("T:Sample.OrderPublisher"); + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderPublisher.", null), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Publisher role signal.", "messaging")); + + ExternalIntegrationEvidenceResult external = ExternalResult( + External( + "ev_framework_only", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Publish, + "masstransit", + "orders", + "queue", + "OrderCreated", + "src/Worker/Publisher.cs", + ExternalIntegrationConfidence.High)); + + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + Assert.Empty(proposal.Components); + Assert.Empty(proposal.Relations); + Assert.Equal(baseModel.Elements.Length, mapped.Elements.Length); + } + + [Fact] + public void DatabaseAndCacheEvidenceWithoutLocalPersistenceDoNotFabricateComponents() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity worker = Symbol("T:Sample.OutboxWorker"); + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OutboxWorker.", null), + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.HostedService, + "semantic.host.backgroundService", "Background worker boundary.", "hostedService")); + + ExternalIntegrationEvidenceResult external = ExternalResult( + External( + "ev_db_unlinked", + ExternalIntegrationKind.Database, + ExternalIntegrationDirection.Read, + "postgresql", + "WorkerDb", + "database", + null, + "src/Worker/DataClient.cs", + ExternalIntegrationConfidence.High), + External( + "ev_cache_unlinked", + ExternalIntegrationKind.Cache, + ExternalIntegrationDirection.Read, + "redis", + "WorkerCache", + "cache", + null, + "src/Worker/CacheClient.cs", + ExternalIntegrationConfidence.High)); + + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + Assert.Single(proposal.Components); + Assert.Equal( + SemanticC3ComponentCategory.BackgroundWorker, + proposal.Components[0].Category); + Assert.Empty(proposal.Relations); + } + + [Fact] + public void MultipleMessagingAdaptersAndLowConfidenceStayDistinctAndReviewable() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity publisher = Symbol("T:Sample.OrderPublisher"); + SemanticC3SourceSymbolIdentity consumer = Symbol("T:Sample.OrderConsumer"); + + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderPublisher.", null), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Publisher role signal.", "messaging"), + Fact("src/Worker/Consumer.cs", consumer, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OrderConsumer.", null), + Fact("src/Worker/Consumer.cs", consumer, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Consumer role signal.", "messaging")); + + ExternalIntegrationEvidenceResult external = ExternalResult( + External( + "ev_pub_low", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Publish, + "rabbitmq", + "orders.exchange", + "exchange", + "OrderCreated", + "src/Worker/Publisher.cs", + ExternalIntegrationConfidence.Low), + External( + "ev_consume_high", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Consume, + "rabbitmq", + "orders.queue", + "queue", + "OrderCreated", + "src/Worker/Consumer.cs", + ExternalIntegrationConfidence.High)); + + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal proposal = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + Assert.Equal( + 2, + proposal.Components.Count(component => + component.Category is SemanticC3ComponentCategory.MessagingPublisher + or SemanticC3ComponentCategory.MessagingConsumer)); + SemanticC3ComponentCandidate publisherComponent = Assert.Single( + proposal.Components.Where(component => + component.Category == SemanticC3ComponentCategory.MessagingPublisher)); + Assert.Contains("low", publisherComponent.ReviewReason!, StringComparison.OrdinalIgnoreCase); + Assert.All(proposal.Components, component => + Assert.Equal(ReviewStatus.RequiresReview, component.Status)); + } + + [Fact] + public void OutputIdsNamesAndRelationsAreDeterministic() + { + ArchitectureModel baseModel = BaseModel(); + SemanticC3SourceSymbolIdentity worker = Symbol("T:Sample.OutboxWorker"); + SemanticC3SourceSymbolIdentity publisher = Symbol("T:Sample.RabbitPublisher"); + + SemanticC3FactSet facts = FactSet( + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol OutboxWorker.", null), + Fact("src/Worker/Worker.cs", worker, SemanticC3FactKind.HostedService, + "semantic.host.backgroundService", "Background worker boundary.", "hostedService"), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.TypeDeclaration, + "semantic.symbol.type", "Declares class symbol RabbitPublisher.", null), + Fact("src/Worker/Publisher.cs", publisher, SemanticC3FactKind.MessagingCandidate, + "semantic.messaging.abstraction", "Publisher role signal.", "messaging")); + + ExternalIntegrationEvidence publish = External( + "ev_publish", + ExternalIntegrationKind.Messaging, + ExternalIntegrationDirection.Publish, + "rabbitmq", + "orders.exchange", + "exchange", + "OrderCreated", + "src/Worker/Publisher.cs", + ExternalIntegrationConfidence.High); + ExternalIntegrationEvidenceResult external = ExternalResult(publish); + ArchitectureModel mapped = ExternalIntegrationArchitectureMapper.Map( + baseModel, + external, + "el_system"); + + SemanticC3Proposal first = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + SemanticC3Proposal repeated = SemanticC3WorkerIntegrationProposer.Propose( + mapped, + ContainerId, + facts, + external); + + Assert.Equal( + SemanticC3ContractJson.Serialize(first), + SemanticC3ContractJson.Serialize(repeated)); + } + + private static ArchitectureModel BaseModel() + { + RepositoryFile[] files = + [ + new("Repo.slnx", 1, null), + new(ProjectPath, 1, null), + new("src/Worker/Worker.cs", 1, null), + new("src/Worker/Program.cs", 1, null), + new("src/Worker/Publisher.cs", 1, null), + new("src/Worker/Consumer.cs", 1, null), + new("src/Worker/Data.cs", 1, null), + new("src/Worker/DataClient.cs", 1, null), + new("src/Worker/CacheClient.cs", 1, null), + new("src/Worker/Repository.cs", 1, null), + ]; + Evidence[] evidence = + [ + new( + "ev_system", + "solution", + "Repo.slnx", + 1, + EvidenceSourceType.Manifest, + "Solution is present."), + new( + "ev_worker_project", + "dotnet.project", + ProjectPath, + 1, + EvidenceSourceType.ProjectFile, + "Worker project is present."), + ]; + + return new ArchitectureModel( + ContractSchema.Version, + ArchitectureLevel.C2, + new RepositorySnapshot( + ContractSchema.Version, + "semantic_worker_test", + [.. files], + [.. evidence], + []), + [ + new ArchitectureElement( + "el_system", + ArchitectureElementKind.SoftwareSystem, + "Orders", + null, + ["ev_system"], + ReviewStatus.Confirmed, + null), + new ArchitectureElement( + ContainerId, + ArchitectureElementKind.Container, + "Worker", + "el_system", + ["ev_worker_project"], + ReviewStatus.Confirmed, + null), + ], + []); + } + + private static SemanticC3FactSet FactSet(params SemanticC3Fact[] facts) => + new( + SemanticC3ContractSchema.Version, + [.. facts], + []); + + private static SemanticC3SourceSymbolIdentity Symbol(string symbolId) => + new( + StableIds.ForSemanticC3SourceSymbol(ProjectPath, symbolId), + ProjectPath, + symbolId); + + private static SemanticC3Fact Fact( + string sourcePath, + SemanticC3SourceSymbolIdentity symbol, + SemanticC3FactKind kind, + string category, + string description, + string? relatedSymbolId) => + new( + StableIds.ForSemanticC3Fact( + ProjectPath, + sourcePath, + symbol.Id, + kind, + category, + relatedSymbolId ?? string.Empty), + ProjectPath, + sourcePath, + 1, + symbol, + kind, + category, + description, + relatedSymbolId); + + private static ExternalIntegrationEvidenceResult ExternalResult( + params ExternalIntegrationEvidence[] evidence) => + new( + ExternalIntegrationReportSchema.MinimumVersion, + "semantic-worker-test", + "abcdef0", + true, + false, + [.. evidence], + []); + + private static ExternalIntegrationEvidence External( + string id, + ExternalIntegrationKind kind, + ExternalIntegrationDirection direction, + string technology, + string target, + string? resourceType, + string? contract, + string sourcePath, + ExternalIntegrationConfidence confidence) + { + string category = (kind, direction) switch + { + (ExternalIntegrationKind.Messaging, ExternalIntegrationDirection.Publish) => + "external.messaging.publish", + (ExternalIntegrationKind.Messaging, ExternalIntegrationDirection.Consume) => + "external.messaging.consume", + (ExternalIntegrationKind.Database, _) => "external.database", + (ExternalIntegrationKind.Cache, _) => "external.cache", + _ => "external.integration", + }; + + return new ExternalIntegrationEvidence( + id, + category, + "Normalized integration evidence.", + id, + ProjectPath, + kind, + direction, + technology, + target, + resourceType, + null, + contract, + sourcePath, + 1, + confidence, + []); + } +} From 827162e9fc0e789ab19b5e60319d8c72bae1e41b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:48:15 -0300 Subject: [PATCH 15/74] style: format semantic worker proposal (#65) --- .../C3/SemanticC3WorkerIntegrationProposer.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs index dc9742f2..9e93eb56 100644 --- a/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs +++ b/src/Repo2C4.Core/C3/SemanticC3WorkerIntegrationProposer.cs @@ -1108,9 +1108,17 @@ private sealed class WorkerAccumulator(SemanticC3SourceSymbolIdentity sourceSymb public HashSet SourcePaths { get; } = new(StringComparer.Ordinal); - public bool DirectBoundary { get; set; } + public bool DirectBoundary + { + get; + set; + } - public bool RegisteredBoundary { get; set; } + public bool RegisteredBoundary + { + get; + set; + } } private sealed record WorkerCandidate( From 3fca3419b0ea8fab6305d94b24c7cb50346d1519 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 17:52:10 -0300 Subject: [PATCH 16/74] docs: define semantic worker integration policy (#65) --- CHANGELOG.md | 1 + docs/c4-mapping.md | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54698282..adda9019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Reviewable Semantic C3 worker/messaging/persistence proposals that merge hosted-service evidence, distinguish supported publish/consume adapters through Integration Evidence, require observed use before promoting repository-shaped persistence, reuse existing C1/C2 external peers, and keep weak/unlinked evidence conservative. - Reviewable Semantic C3 HTTP/application proposals that aggregate routes by semantic boundary and distinguish DI-/handler-backed application services from name-only classes. - Bounded, cancellation-aware C# structural fact extraction for Semantic C3, covering symbols, DI/wiring, Minimal API/controllers, hosted services, DbContext, messaging role signals and conservative intra-project collaboration facts without build/code execution or source/literal propagation. - Additive, versioned Semantic C3 contract foundation with deterministic component taxonomy, stable source-symbol identity, reviewable internal/external relations, conceptual multi-container selection, and a reproducible v1.1.0 dogfooding baseline. diff --git a/docs/c4-mapping.md b/docs/c4-mapping.md index 163d8f71..e9a9aa09 100644 --- a/docs/c4-mapping.md +++ b/docs/c4-mapping.md @@ -137,6 +137,22 @@ Semantic C3 remains additive to the stable C1/C2 model. For HTTP/application pro Component IDs derive from stable source-symbol identity and category, and evidence references use Semantic C3 fact IDs. This keeps route aggregation and naming deterministic across repeated analysis while preserving the facts that motivated each proposal. +## Semantic C3 worker/messaging/persistence policy + +The asynchronous Semantic C3 pass combines local .NET structure with the normalized Integration Evidence boundary instead of treating package/framework names as architecture. + +- A concrete `BackgroundService` / `IHostedService` symbol is one worker candidate. `AddHostedService` evidence is merged into the same symbol-derived candidate instead of creating a second component. Registration-only boundaries remain `requiresReview`. +- The worker host and its internal processing responsibility stay aggregated when the evidence exposes only one hosted-service boundary. They are split only when later evidence supports a distinct internal responsibility; class or method names alone do not justify that split. +- A messaging adapter requires both a concrete local publisher/consumer-shaped symbol and supported Integration Evidence for the same project/direction. The imported evidence supplies publish vs consume, provider/technology, resource and contract metadata. +- A messaging component is stable per local source symbol, direction/category and provider. Multiple findings for the same adapter merge provenance; different providers or distinct local adapter symbols remain distinct. +- Framework-only `masstransit` / `nservicebus`, unknown transports, missing targets, or unmatched local role evidence do not create a broker adapter or remote peer. +- `DbContext` inheritance is a strong local persistence-boundary signal and may produce a reviewable persistence candidate without modeling entities or `DbSet` values. +- A repository-shaped implementation is only promoted when its use is structurally observable from a worker/messaging boundary through DI registration plus constructor injection, or through direct local symbol collaboration. A type merely named `Repository` is insufficient. +- Database/cache package or Integration Evidence without a semantically linked local persistence responsibility does not fabricate a persistence component. Cache evidence is not reclassified as database persistence. +- External Semantic C3 relations are attached only when the same evidence ID already participates in an existing C1/C2 external relation involving the selected container. The C3 pass reuses that existing architecture element; it never invents the other endpoint. +- Low/medium confidence and all newly proposed C3 boundaries remain explicit `requiresReview` assertions. Resource, contract and provider detail remain provenance/relation metadata rather than extra C4 components. +- General component-to-component collaboration (worker → publisher, worker → application processing, application → persistence, and similar internal flow) is intentionally deferred to the dedicated internal-relation policy. + ## Out of scope -This policy does not implement LikeC4 emission, LikeC4 validation, CLI commands, MCP transport, AI calls, C3, external services or automatic PR generation. +The C1/C2 mapping policy itself does not implement LikeC4 emission, CLI/MCP/Agent orchestration, AI calls, runtime tracing or automatic PR generation. The additive Semantic C3 policies above only define conservative component proposals; generation/orchestration is handled by later phases. From 524a541a3989e65ecaa1258da66102ad4235e55c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 19:36:17 -0300 Subject: [PATCH 17/74] feat: build semantic C3 relations with confidence policy (#66) --- .../C3/SemanticC3RelationBuilder.cs | 1099 +++++++++++++++++ .../EvidenceReportGenerator.SemanticC3.cs | 239 ++++ .../Review/EvidenceReportGenerator.cs | 2 +- 3 files changed, 1339 insertions(+), 1 deletion(-) create mode 100644 src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs create mode 100644 src/Repo2C4.Core/Review/EvidenceReportGenerator.SemanticC3.cs diff --git a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs new file mode 100644 index 00000000..9b86e0cb --- /dev/null +++ b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs @@ -0,0 +1,1099 @@ +using System.Collections.Immutable; +using Repo2C4.Core.Contracts; + +namespace Repo2C4.Core.C3; + +/// Deterministic budgets applied while composing the bounded Semantic C3 graph. +public sealed record SemanticC3RelationBuildOptions +{ + public int MaxComponents { get; init; } = 512; + + public int MaxRelations { get; init; } = 1_024; +} + +/// Semantic C3 graph plus controlled diagnostics for omitted or review-only graph assertions. +public sealed record SemanticC3RelationBuildResult( + SemanticC3Proposal Proposal, + ImmutableArray Diagnostics); + +/// +/// Builds bounded component-to-component and component-to-existing-architecture relations from structural facts. +/// It intentionally ignores project references, names and isolated type declarations as runtime-flow evidence. +/// +public static class SemanticC3RelationBuilder +{ + public static SemanticC3RelationBuildResult Build( + ArchitectureModel baseModel, + SemanticC3Proposal proposal, + SemanticC3FactSet factSet, + SemanticC3RelationBuildOptions? options = null) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentNullException.ThrowIfNull(proposal); + ArgumentNullException.ThrowIfNull(factSet); + options ??= new SemanticC3RelationBuildOptions(); + + ValidateInputs(baseModel, proposal, factSet, options); + + List diagnostics = []; + SemanticC3ComponentCandidate[] components = + [ + .. proposal.Components + .OrderBy(component => component.Id, StringComparer.Ordinal) + .Take(options.MaxComponents), + ]; + + if (proposal.Components.Length > options.MaxComponents) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.componentBudget", + DiagnosticSeverity.Warning, + null, + "Semantic C3 component budget reached; relation analysis used the deterministic first component subset.")); + } + + Dictionary componentsById = + components.ToDictionary(component => component.Id, StringComparer.Ordinal); + ComponentIndex componentIndex = new(components); + SemanticC3Fact[] facts = + [ + .. factSet.Facts + .Where(fact => components.Any(component => + component.SourceSymbol?.ProjectPath == fact.ProjectPath)) + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + + DiRegistration[] registrations = + [ + .. facts + .Where(fact => fact.Kind == SemanticC3FactKind.DependencyInjectionRegistration) + .Select(ParseRegistration) + .Where(registration => registration is not null) + .Select(registration => registration!) + .OrderBy(registration => registration.Fact.Id, StringComparer.Ordinal), + ]; + + Dictionary> interfaces = + BuildInterfaceIndex(facts, componentIndex); + HandlerIndex handlerIndex = BuildHandlerIndex(facts, componentIndex); + HashSet controllerActions = facts + .Where(fact => fact.Kind == SemanticC3FactKind.ControllerAction) + .Select(fact => fact.SourceSymbol.Id) + .ToHashSet(StringComparer.Ordinal); + + Dictionary signals = []; + + foreach (SemanticC3Fact fact in facts) + { + switch (fact.Kind) + { + case SemanticC3FactKind.EndpointDependency: + AddWiringSignal( + fact, + componentIndex.FindExact(fact.SourceSymbol), + ResolveDependency( + fact.RelatedSymbolId, + componentIndex, + registrations, + interfaces), + signals); + break; + + case SemanticC3FactKind.ConstructorInjection: + AddWiringSignal( + fact, + componentIndex.FindByMethodOwner(fact.SourceSymbol), + ResolveDependency( + fact.RelatedSymbolId, + componentIndex, + registrations, + interfaces), + signals); + break; + + case SemanticC3FactKind.MethodParameter: + SemanticC3ComponentCandidate? parameterSource = + handlerIndex.MethodOwners.TryGetValue( + fact.SourceSymbol.Id, + out HandlerOwner? handlerOwner) + ? handlerOwner.Component + : controllerActions.Contains(fact.SourceSymbol.Id) + ? componentIndex.FindByMethodOwner(fact.SourceSymbol) + : null; + + if (parameterSource is not null) + { + DependencyResolution parameterDestination = ResolveDependency( + fact.RelatedSymbolId, + componentIndex, + registrations, + interfaces); + AddWiringSignal( + fact, + parameterSource, + parameterDestination, + signals, + handlerIndex.MethodOwners.TryGetValue( + fact.SourceSymbol.Id, + out HandlerOwner? owner) + ? owner.HandlerEvidenceId + : null); + } + + break; + + case SemanticC3FactKind.SymbolInvocation: + SemanticC3ComponentCandidate? invocationSource = + handlerIndex.MethodOwners.TryGetValue( + fact.SourceSymbol.Id, + out HandlerOwner? invocationHandler) + ? invocationHandler.Component + : componentIndex.FindByMethodOwner(fact.SourceSymbol); + SemanticC3ComponentCandidate? invocationDestination = + ResolveInvocationDestination(fact.RelatedSymbolId, componentIndex); + AddInvocationSignal( + fact, + invocationSource, + invocationDestination, + signals, + invocationHandler?.HandlerEvidenceId); + break; + } + } + + List generatedInternal = + BuildInternalRelations(signals, diagnostics); + + List allRelations = []; + allRelations.AddRange(NormalizeExistingRelations( + proposal.Relations, + componentsById, + baseModel, + diagnostics)); + allRelations.AddRange(generatedInternal); + allRelations.AddRange(BuildExternalRelations( + baseModel, + components, + diagnostics)); + + SemanticC3RelationCandidate[] normalized = NormalizeRelations(allRelations); + SemanticC3RelationCandidate[] boundedRelations = + [ + .. normalized + .Where(relation => + componentsById.ContainsKey(relation.SourceComponentId) && + (relation.DestinationKind != SemanticC3RelationTargetKind.Component || + componentsById.ContainsKey(relation.DestinationId))) + .OrderBy(relation => relation.Id, StringComparer.Ordinal) + .Take(options.MaxRelations), + ]; + + if (normalized.Length > options.MaxRelations) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.relationBudget", + DiagnosticSeverity.Warning, + null, + "Semantic C3 relation budget reached; output was deterministically truncated.")); + } + + SemanticC3Proposal result = new( + SemanticC3ContractSchema.Version, + [ + .. components + .Select(component => component.ContainerId) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + [.. components], + [.. boundedRelations]); + + ImmutableArray errors = SemanticC3Validator.Validate(result); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return new SemanticC3RelationBuildResult( + result, + [.. diagnostics + .OrderBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal)]); + } + + private static List BuildInternalRelations( + Dictionary signals, + List diagnostics) + { + List seeds = []; + + foreach ((RelationKey key, SignalAccumulator signal) in signals + .OrderBy(item => item.Key.SourceId, StringComparer.Ordinal) + .ThenBy(item => item.Key.DestinationId, StringComparer.Ordinal)) + { + if (key.SourceId == key.DestinationId) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.selfLoopOmitted", + DiagnosticSeverity.Info, + null, + "A Semantic C3 self-loop without explicit architectural semantics was omitted.")); + continue; + } + + if (signal.Source.ContainerId != signal.Destination.ContainerId) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.crossContainerInternalOmitted", + DiagnosticSeverity.Info, + null, + "A component collaboration crossing container boundaries was not emitted as an internal C3 relation.")); + continue; + } + + if (!IsSupportedPair(signal.Source.Category, signal.Destination.Category)) + { + continue; + } + + bool confirmed = signal.HasWiring && signal.HasInvocation; + seeds.Add(new InternalRelationSeed( + signal, + confirmed ? ReviewStatus.Confirmed : ReviewStatus.RequiresReview, + confirmed ? null : ReviewReason(signal), + Description(signal.Source, signal.Destination))); + } + + HashSet omit = []; + Dictionary<(string A, string B), List> reciprocalGroups = + seeds.GroupBy(seed => OrderedPair(seed.Signal.Source.Id, seed.Signal.Destination.Id)) + .ToDictionary(group => group.Key, group => group.ToList()); + + foreach (List group in reciprocalGroups.Values) + { + if (group.Count < 2) + { + continue; + } + + InternalRelationSeed[] directions = + [ + .. group + .GroupBy(seed => new RelationKey( + seed.Signal.Source.Id, + seed.Signal.Destination.Id)) + .Select(items => items.First()), + ]; + if (directions.Length < 2) + { + continue; + } + + InternalRelationSeed[] confirmed = + [ + .. directions.Where(seed => seed.Status == ReviewStatus.Confirmed), + ]; + + if (confirmed.Length == 0) + { + foreach (InternalRelationSeed seed in directions) + { + omit.Add(new RelationKey( + seed.Signal.Source.Id, + seed.Signal.Destination.Id)); + } + + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.weakCycleOmitted", + DiagnosticSeverity.Info, + null, + "Reciprocal review-only source references were omitted to avoid fabricating an architectural cycle.")); + } + else if (confirmed.Length == 1) + { + foreach (InternalRelationSeed seed in directions.Where(seed => + seed.Status == ReviewStatus.RequiresReview)) + { + omit.Add(new RelationKey( + seed.Signal.Source.Id, + seed.Signal.Destination.Id)); + } + } + } + + return + [ + .. seeds + .Where(seed => !omit.Contains(new RelationKey( + seed.Signal.Source.Id, + seed.Signal.Destination.Id))) + .Select(seed => new SemanticC3RelationCandidate( + StableIds.ForRelation( + seed.Signal.Source.Id, + seed.Signal.Destination.Id, + "semantic-c3|internal|" + + seed.Signal.Source.Category + "|" + + seed.Signal.Destination.Category), + seed.Signal.Source.Id, + seed.Signal.Destination.Id, + SemanticC3RelationTargetKind.Component, + seed.Description, + [.. seed.Signal.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + seed.Status, + seed.ReviewReason)) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + } + + private static IEnumerable BuildExternalRelations( + ArchitectureModel baseModel, + SemanticC3ComponentCandidate[] components, + List diagnostics) + { + HashSet architectureElementIds = baseModel.Elements + .Select(element => element.Id) + .ToHashSet(StringComparer.Ordinal); + + foreach (SemanticC3ComponentCandidate component in components + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + ArchitectureRelation[] matches = + [ + .. baseModel.Relations + .Where(relation => + relation.SourceId == component.ContainerId || + relation.DestinationId == component.ContainerId) + .Where(relation => + relation.EvidenceIds.Intersect( + component.EvidenceIds, + StringComparer.Ordinal).Any()) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + + foreach (ArchitectureRelation relation in matches) + { + string destinationId = relation.SourceId == component.ContainerId + ? relation.DestinationId + : relation.SourceId; + + if (!architectureElementIds.Contains(destinationId) || + destinationId == component.ContainerId) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.externalEndpointMissing", + DiagnosticSeverity.Warning, + null, + "A component-to-external relation was omitted because its existing C1/C2 peer is unavailable.")); + continue; + } + + yield return new SemanticC3RelationCandidate( + StableIds.ForRelation( + component.Id, + destinationId, + "semantic-c3|external|" + relation.Description), + component.Id, + destinationId, + SemanticC3RelationTargetKind.ArchitectureElement, + relation.Description, + [ + .. component.EvidenceIds + .Intersect(relation.EvidenceIds, StringComparer.Ordinal) + .Concat(relation.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + relation.Status == ReviewStatus.Confirmed + ? ReviewStatus.Confirmed + : ReviewStatus.RequiresReview, + relation.Status == ReviewStatus.Confirmed + ? null + : "The existing C1/C2 external relation still requires review; the C3 adapter association cannot be stronger than its peer mapping."); + } + } + } + + private static IEnumerable NormalizeExistingRelations( + ImmutableArray relations, + Dictionary componentsById, + ArchitectureModel baseModel, + List diagnostics) + { + HashSet architectureElementIds = baseModel.Elements + .Select(element => element.Id) + .ToHashSet(StringComparer.Ordinal); + + foreach (SemanticC3RelationCandidate relation in relations + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + if (!componentsById.TryGetValue( + relation.SourceComponentId, + out SemanticC3ComponentCandidate? source)) + { + continue; + } + + if (relation.DestinationKind == SemanticC3RelationTargetKind.Component) + { + if (!componentsById.TryGetValue( + relation.DestinationId, + out SemanticC3ComponentCandidate? destination)) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.internalEndpointMissing", + DiagnosticSeverity.Warning, + null, + "An existing Semantic C3 relation was omitted because a component endpoint is unavailable.")); + continue; + } + + if (source.ContainerId != destination.ContainerId) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.crossContainerInternalOmitted", + DiagnosticSeverity.Info, + null, + "An existing cross-container component edge was omitted from the internal C3 graph.")); + continue; + } + } + else if (!architectureElementIds.Contains(relation.DestinationId)) + { + diagnostics.Add(new RepositoryDiagnostic( + "semanticC3.externalEndpointMissing", + DiagnosticSeverity.Warning, + null, + "An existing Semantic C3 external relation was omitted because its C1/C2 element does not exist.")); + continue; + } + + yield return relation; + } + } + + private static SemanticC3RelationCandidate[] NormalizeRelations( + IEnumerable relations) + { + return + [ + .. relations + .GroupBy( + relation => new RelationSemanticKey( + relation.SourceComponentId, + relation.DestinationId, + relation.DestinationKind, + relation.Description)) + .Select(group => + { + SemanticC3RelationCandidate[] items = + [ + .. group.OrderBy(item => item.Id, StringComparer.Ordinal), + ]; + SemanticC3RelationCandidate first = items[0]; + bool confirmed = items.Any(item => item.Status == ReviewStatus.Confirmed); + string stableKey = "semantic-c3|normalized|" + + first.DestinationKind + "|" + first.Description; + + return new SemanticC3RelationCandidate( + StableIds.ForRelation( + first.SourceComponentId, + first.DestinationId, + stableKey), + first.SourceComponentId, + first.DestinationId, + first.DestinationKind, + first.Description, + [ + .. items + .SelectMany(item => item.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + confirmed ? ReviewStatus.Confirmed : ReviewStatus.RequiresReview, + confirmed + ? null + : items + .Select(item => item.ReviewReason) + .Where(reason => !string.IsNullOrWhiteSpace(reason)) + .OrderBy(reason => reason, StringComparer.Ordinal) + .FirstOrDefault() ?? + "The Semantic C3 relation requires architectural review."); + }) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + } + + private static void AddWiringSignal( + SemanticC3Fact fact, + SemanticC3ComponentCandidate? source, + DependencyResolution destination, + Dictionary signals, + string? handlerEvidenceId = null) + { + if (source is null || destination.Component is null) + { + return; + } + + SignalAccumulator signal = GetSignal(signals, source, destination.Component); + signal.HasWiring = true; + signal.EvidenceIds.Add(fact.Id); + if (handlerEvidenceId is not null) + { + signal.EvidenceIds.Add(handlerEvidenceId); + } + + foreach (string evidenceId in destination.EvidenceIds) + { + signal.EvidenceIds.Add(evidenceId); + } + } + + private static void AddInvocationSignal( + SemanticC3Fact fact, + SemanticC3ComponentCandidate? source, + SemanticC3ComponentCandidate? destination, + Dictionary signals, + string? handlerEvidenceId) + { + if (source is null || destination is null) + { + return; + } + + SignalAccumulator signal = GetSignal(signals, source, destination); + signal.HasInvocation = true; + signal.EvidenceIds.Add(fact.Id); + if (handlerEvidenceId is not null) + { + signal.EvidenceIds.Add(handlerEvidenceId); + } + } + + private static SignalAccumulator GetSignal( + Dictionary signals, + SemanticC3ComponentCandidate source, + SemanticC3ComponentCandidate destination) + { + RelationKey key = new(source.Id, destination.Id); + if (!signals.TryGetValue(key, out SignalAccumulator? signal)) + { + signal = new SignalAccumulator(source, destination); + signals.Add(key, signal); + } + + return signal; + } + + private static DependencyResolution ResolveDependency( + string? relatedSymbolId, + ComponentIndex index, + DiRegistration[] registrations, + Dictionary> interfaces) + { + string? typeName = RelatedTypeName(relatedSymbolId); + if (typeName is null) + { + return DependencyResolution.Empty; + } + + string simple = SimpleTypeName(typeName); + HashSet evidenceIds = new(StringComparer.Ordinal); + + DiRegistration[] registrationMatches = + [ + .. registrations.Where(registration => + SameType(registration.ServiceType, typeName) || + SameType(registration.ImplementationType, typeName)), + ]; + + foreach (DiRegistration registration in registrationMatches) + { + SemanticC3ComponentCandidate? component = + index.FindByTypeName(registration.ImplementationType); + if (component is null) + { + continue; + } + + evidenceIds.Add(registration.Fact.Id); + AddInterfaceEvidence(interfaces, simple, component.Id, evidenceIds); + return new DependencyResolution(component, [.. evidenceIds]); + } + + SemanticC3ComponentCandidate? direct = index.FindByTypeName(typeName); + if (direct is not null) + { + AddInterfaceEvidence(interfaces, simple, direct.Id, evidenceIds); + return new DependencyResolution(direct, [.. evidenceIds]); + } + + if (interfaces.TryGetValue(simple, out List? implementations)) + { + InterfaceImplementation[] unique = + [ + .. implementations + .GroupBy(item => item.Component.Id, StringComparer.Ordinal) + .Select(group => group.First()), + ]; + if (unique.Length == 1) + { + evidenceIds.Add(unique[0].Fact.Id); + return new DependencyResolution( + unique[0].Component, + [.. evidenceIds]); + } + } + + return DependencyResolution.Empty; + } + + private static SemanticC3ComponentCandidate? ResolveInvocationDestination( + string? relatedSymbolId, + ComponentIndex index) + { + string? targetType = RelatedMethodTypeName(relatedSymbolId); + return targetType is null + ? null + : index.FindByTypeName(targetType); + } + + private static Dictionary> BuildInterfaceIndex( + SemanticC3Fact[] facts, + ComponentIndex index) + { + Dictionary> result = + new(StringComparer.OrdinalIgnoreCase); + + foreach (SemanticC3Fact fact in facts.Where(item => + item.Kind == SemanticC3FactKind.ImplementedInterface)) + { + string? interfaceName = RelatedInterfaceName(fact.RelatedSymbolId); + SemanticC3ComponentCandidate? component = index.FindExact(fact.SourceSymbol); + if (interfaceName is null || component is null) + { + continue; + } + + string key = SimpleTypeName(interfaceName); + if (!result.TryGetValue(key, out List? list)) + { + list = []; + result.Add(key, list); + } + + list.Add(new InterfaceImplementation(component, fact)); + } + + return result; + } + + private static void AddInterfaceEvidence( + Dictionary> interfaces, + string interfaceName, + string componentId, + HashSet evidenceIds) + { + if (!interfaces.TryGetValue( + SimpleTypeName(interfaceName), + out List? implementations)) + { + return; + } + + foreach (InterfaceImplementation implementation in implementations.Where(item => + item.Component.Id == componentId)) + { + evidenceIds.Add(implementation.Fact.Id); + } + } + + private static HandlerIndex BuildHandlerIndex( + SemanticC3Fact[] facts, + ComponentIndex componentIndex) + { + Dictionary owners = new(StringComparer.Ordinal); + + foreach (SemanticC3Fact handler in facts.Where(fact => + fact.Kind == SemanticC3FactKind.EndpointHandler)) + { + SemanticC3ComponentCandidate? endpoint = + componentIndex.FindExact(handler.SourceSymbol); + if (endpoint is null || + endpoint.Category != SemanticC3ComponentCategory.HttpEndpoint) + { + continue; + } + + SemanticC3Fact[] methods = + [ + .. facts.Where(fact => + fact.Kind == SemanticC3FactKind.MethodDeclaration && + MatchesMethodTarget( + fact.SourceSymbol.SymbolId, + handler.RelatedSymbolId)), + ]; + if (methods.Length == 1) + { + owners[methods[0].SourceSymbol.Id] = + new HandlerOwner(endpoint, handler.Id); + } + } + + return new HandlerIndex(owners); + } + + private static bool IsSupportedPair( + SemanticC3ComponentCategory source, + SemanticC3ComponentCategory destination) => + (source, destination) switch + { + (SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService) => true, + (SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter) => true, + (SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.IntegrationAdapter) => true, + (SemanticC3ComponentCategory.BackgroundWorker, + SemanticC3ComponentCategory.MessagingPublisher) => true, + (SemanticC3ComponentCategory.MessagingConsumer, + SemanticC3ComponentCategory.ApplicationService) => true, + _ => false, + }; + + private static string Description( + SemanticC3ComponentCandidate source, + SemanticC3ComponentCandidate destination) => + (source.Category, destination.Category) switch + { + (SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService) => + "Delegates HTTP processing to " + destination.Name, + (SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter) => + "Uses " + destination.Name + " for persistence", + (SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.IntegrationAdapter) => + "Calls " + destination.Name, + (SemanticC3ComponentCategory.BackgroundWorker, + SemanticC3ComponentCategory.MessagingPublisher) => + "Publishes through " + destination.Name, + (SemanticC3ComponentCategory.MessagingConsumer, + SemanticC3ComponentCategory.ApplicationService) => + "Delegates consumed messages to " + destination.Name, + _ => "Collaborates with " + destination.Name, + }; + + private static string ReviewReason(SignalAccumulator signal) + { + if (signal.HasWiring && !signal.HasInvocation) + { + return "DI/handler wiring supports the collaboration, but no direct symbol invocation was observed; confirm runtime flow."; + } + + if (!signal.HasWiring && signal.HasInvocation) + { + return "A direct symbol invocation was observed without supporting DI/handler wiring; confirm the architectural collaboration."; + } + + return "The available structural signals are insufficient to confirm runtime collaboration."; + } + + private static (string A, string B) OrderedPair(string first, string second) => + string.CompareOrdinal(first, second) <= 0 + ? (first, second) + : (second, first); + + private static DiRegistration? ParseRegistration(SemanticC3Fact fact) + { + string? related = fact.RelatedSymbolId; + if (related is null || !related.StartsWith("DI:", StringComparison.Ordinal)) + { + return null; + } + + int lifetime = related.IndexOf(':', 3); + int arrow = related.IndexOf("->", StringComparison.Ordinal); + int ordinal = related.LastIndexOf(':'); + if (lifetime < 0 || arrow <= lifetime || ordinal <= arrow + 2) + { + return null; + } + + string service = related[(lifetime + 1)..arrow]; + string implementation = related[(arrow + 2)..ordinal]; + return string.IsNullOrWhiteSpace(service) || + string.IsNullOrWhiteSpace(implementation) + ? null + : new DiRegistration(service, implementation, fact); + } + + private static string? RelatedTypeName(string? related) + { + if (related is null || !related.StartsWith("T:", StringComparison.Ordinal)) + { + return null; + } + + int ordinal = related.LastIndexOf(':'); + return ordinal <= 2 ? null : related[2..ordinal]; + } + + private static string? RelatedInterfaceName(string? related) + { + if (related is null || !related.StartsWith("I:", StringComparison.Ordinal)) + { + return null; + } + + int ordinal = related.LastIndexOf(':'); + return ordinal <= 2 ? null : related[2..ordinal]; + } + + private static string? RelatedMethodTypeName(string? related) + { + if (related is null || !related.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + string target = related[2..]; + int method = target.LastIndexOf('.'); + return method <= 0 ? null : target[..method]; + } + + private static string? DeclaringTypeId(string methodSymbolId) + { + if (!methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + int parameters = methodSymbolId.IndexOf('('); + string head = parameters >= 0 + ? methodSymbolId[..parameters] + : methodSymbolId; + int separator = head.LastIndexOf('.'); + return separator <= 2 ? null : "T:" + head[2..separator]; + } + + private static bool MatchesMethodTarget( + string methodSymbolId, + string? relatedTarget) + { + if (relatedTarget is null || + !relatedTarget.StartsWith("M:", StringComparison.Ordinal) || + !methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return false; + } + + int parameters = methodSymbolId.IndexOf('('); + string methodHead = parameters >= 0 + ? methodSymbolId[..parameters] + : methodSymbolId; + string target = relatedTarget[2..]; + string candidate = methodHead[2..]; + + return candidate.Equals(target, StringComparison.Ordinal) || + candidate.EndsWith("." + target, StringComparison.Ordinal); + } + + private static bool SameType(string left, string right) => + string.Equals( + NormalizeTypeName(left), + NormalizeTypeName(right), + StringComparison.OrdinalIgnoreCase) || + string.Equals( + SimpleTypeName(left), + SimpleTypeName(right), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeTypeName(string value) + { + string normalized = value.Trim() + .Replace("global::", string.Empty, StringComparison.Ordinal) + .TrimEnd('?'); + + int generic = normalized.IndexOf('<'); + if (generic >= 0) + { + normalized = normalized[..generic]; + } + + while (normalized.EndsWith("[]", StringComparison.Ordinal)) + { + normalized = normalized[..^2]; + } + + return normalized; + } + + private static string SimpleTypeName(string value) + { + string normalized = NormalizeTypeName(value); + int dot = normalized.LastIndexOf('.'); + return dot < 0 ? normalized : normalized[(dot + 1)..]; + } + + private static void ValidateInputs( + ArchitectureModel baseModel, + SemanticC3Proposal proposal, + SemanticC3FactSet factSet, + SemanticC3RelationBuildOptions options) + { + ImmutableArray modelErrors = + ContractValidator.ValidateModel(baseModel); + if (!modelErrors.IsEmpty) + { + throw new ContractValidationException(modelErrors); + } + + ImmutableArray proposalErrors = + SemanticC3Validator.Validate(proposal); + if (!proposalErrors.IsEmpty) + { + throw new ContractValidationException(proposalErrors); + } + + if (factSet.SchemaVersion != SemanticC3ContractSchema.Version || + factSet.Facts.IsDefault || + factSet.Diagnostics.IsDefault) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.factsInvalid", + "$.facts", + "Semantic C3 fact set must use the supported schema and initialized collections."), + ]); + } + + if (options.MaxComponents <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "MaxComponents must be positive."); + } + + if (options.MaxRelations <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "MaxRelations must be positive."); + } + } + + private sealed class ComponentIndex(SemanticC3ComponentCandidate[] components) + { + private readonly SemanticC3ComponentCandidate[] _components = components; + + public SemanticC3ComponentCandidate? FindExact( + SemanticC3SourceSymbolIdentity sourceSymbol) => + _components.SingleOrDefault(component => + component.SourceSymbol?.Id == sourceSymbol.Id); + + public SemanticC3ComponentCandidate? FindByMethodOwner( + SemanticC3SourceSymbolIdentity methodSymbol) + { + string? declaringType = DeclaringTypeId(methodSymbol.SymbolId); + return declaringType is null + ? FindExact(methodSymbol) + : _components.SingleOrDefault(component => + component.SourceSymbol?.ProjectPath == methodSymbol.ProjectPath && + component.SourceSymbol.SymbolId == declaringType); + } + + public SemanticC3ComponentCandidate? FindByTypeName(string typeName) + { + string normalized = NormalizeTypeName(typeName); + SemanticC3ComponentCandidate[] exact = + [ + .. _components.Where(component => + component.SourceSymbol is not null && + component.SourceSymbol.SymbolId == "T:" + normalized), + ]; + if (exact.Length == 1) + { + return exact[0]; + } + + string simple = SimpleTypeName(normalized); + SemanticC3ComponentCandidate[] simpleMatches = + [ + .. _components.Where(component => + component.SourceSymbol is not null && + component.SourceSymbol.SymbolId.StartsWith( + "T:", + StringComparison.Ordinal) && + string.Equals( + SimpleTypeName(component.SourceSymbol.SymbolId[2..]), + simple, + StringComparison.OrdinalIgnoreCase)), + ]; + return simpleMatches.Length == 1 ? simpleMatches[0] : null; + } + } + + private sealed class SignalAccumulator( + SemanticC3ComponentCandidate source, + SemanticC3ComponentCandidate destination) + { + public SemanticC3ComponentCandidate Source { get; } = source; + + public SemanticC3ComponentCandidate Destination { get; } = destination; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + + public bool HasWiring + { + get; + set; + } + + public bool HasInvocation + { + get; + set; + } + } + + private readonly record struct RelationKey( + string SourceId, + string DestinationId); + + private readonly record struct RelationSemanticKey( + string SourceId, + string DestinationId, + SemanticC3RelationTargetKind DestinationKind, + string Description); + + private sealed record InternalRelationSeed( + SignalAccumulator Signal, + ReviewStatus Status, + string? ReviewReason, + string Description); + + private sealed record DependencyResolution( + SemanticC3ComponentCandidate? Component, + ImmutableArray EvidenceIds) + { + public static readonly DependencyResolution Empty = new(null, []); + } + + private sealed record DiRegistration( + string ServiceType, + string ImplementationType, + SemanticC3Fact Fact); + + private sealed record InterfaceImplementation( + SemanticC3ComponentCandidate Component, + SemanticC3Fact Fact); + + private sealed record HandlerOwner( + SemanticC3ComponentCandidate Component, + string HandlerEvidenceId); + + private sealed record HandlerIndex( + Dictionary MethodOwners); +} diff --git a/src/Repo2C4.Core/Review/EvidenceReportGenerator.SemanticC3.cs b/src/Repo2C4.Core/Review/EvidenceReportGenerator.SemanticC3.cs new file mode 100644 index 00000000..44356088 --- /dev/null +++ b/src/Repo2C4.Core/Review/EvidenceReportGenerator.SemanticC3.cs @@ -0,0 +1,239 @@ +using System.Collections.Immutable; +using System.Text; +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; + +namespace Repo2C4.Core.Review; + +public static partial class EvidenceReportGenerator +{ + public const string SemanticC3FileName = "semantic-c3-evidence-report.md"; + + public static EvidenceReportResult GenerateSemanticC3( + ArchitectureModel baseModel, + SemanticC3RelationBuildResult result, + SemanticC3FactSet factSet) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(factSet); + + _ = ContractJson.SerializeModel(baseModel); + ImmutableArray errors = + SemanticC3Validator.Validate(result.Proposal); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + Dictionary facts = factSet.Facts + .ToDictionary(fact => fact.Id, StringComparer.Ordinal); + Dictionary architectureEvidence = baseModel.Snapshot.Evidence + .ToDictionary(evidence => evidence.Id, StringComparer.Ordinal); + + StringBuilder builder = new(); + builder.AppendLine("# Semantic C3 evidence report"); + builder.AppendLine(); + builder.AppendLine( + "Generated from bounded Semantic C3 facts and the existing C1/C2 model. Source bodies and configuration values are intentionally omitted."); + builder.AppendLine(); + + builder.AppendLine("## Component candidates"); + builder.AppendLine(); + if (result.Proposal.Components.IsEmpty) + { + builder.AppendLine("No Semantic C3 component candidates."); + } + else + { + foreach (SemanticC3ComponentCandidate component in + result.Proposal.Components.OrderBy(item => item.Id, StringComparer.Ordinal)) + { + builder.AppendLine( + "- Component " + Code(component.Id) + " (" + + PlainText(component.Name) + ", " + + Code(component.Category.ToString()) + ") | signals: " + + SignalList(component.EvidenceIds, facts, architectureEvidence) + + ". Status: " + Code(component.Status.ToString()) + "." + + ReviewSuffix(component.ReviewReason)); + } + } + + builder.AppendLine(); + builder.AppendLine("## Relations"); + builder.AppendLine(); + + if (result.Proposal.Relations.IsEmpty) + { + builder.AppendLine("No Semantic C3 relations."); + } + else + { + foreach (SemanticC3RelationCandidate relation in + result.Proposal.Relations.OrderBy(item => item.Id, StringComparer.Ordinal)) + { + builder.AppendLine( + "- Relation " + Code(relation.Id) + " (" + + Code(relation.SourceComponentId) + " -> " + + Code(relation.DestinationId) + ": " + + PlainText(relation.Description) + ") | evidence: " + + EvidenceList(relation.EvidenceIds, facts, architectureEvidence) + + " | signals: " + + SignalList(relation.EvidenceIds, facts, architectureEvidence) + + " | missing signals: " + + MissingSignals(relation, facts) + + ". Status: " + Code(relation.Status.ToString()) + "." + + ReviewSuffix(relation.ReviewReason)); + } + } + + builder.AppendLine(); + builder.AppendLine("## Graph diagnostics"); + builder.AppendLine(); + if (result.Diagnostics.IsEmpty) + { + builder.AppendLine("No Semantic C3 graph diagnostics."); + } + else + { + foreach (RepositoryDiagnostic diagnostic in + result.Diagnostics.OrderBy(item => item.Code, StringComparer.Ordinal)) + { + builder.AppendLine( + "- " + Code(diagnostic.Code) + " (" + + diagnostic.Severity.ToString().ToLowerInvariant() + "): " + + PlainText(diagnostic.Message)); + } + } + + builder.AppendLine(); + + int confirmed = result.Proposal.Components.Count(item => + item.Status == ReviewStatus.Confirmed) + + result.Proposal.Relations.Count(item => + item.Status == ReviewStatus.Confirmed); + int review = result.Proposal.Components.Count(item => + item.Status == ReviewStatus.RequiresReview) + + result.Proposal.Relations.Count(item => + item.Status == ReviewStatus.RequiresReview); + + return new EvidenceReportResult( + SemanticC3FileName, + builder.ToString().Replace("\r\n", "\n", StringComparison.Ordinal), + new EvidenceReportSummary( + confirmed, + review, + result.Diagnostics.Count(item => + item.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error), + 0)); + } + + private static string SignalList( + IEnumerable evidenceIds, + Dictionary facts, + Dictionary architectureEvidence) + { + string[] signals = + [ + .. evidenceIds + .Distinct(StringComparer.Ordinal) + .Select(id => facts.TryGetValue(id, out SemanticC3Fact? fact) + ? fact.Category + : architectureEvidence.TryGetValue(id, out Evidence? evidence) + ? evidence.Category + : "missing") + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal), + ]; + + return signals.Length == 0 + ? "none" + : string.Join(", ", signals.Select(Code)); + } + + private static string EvidenceList( + IEnumerable evidenceIds, + Dictionary facts, + Dictionary architectureEvidence) + { + string[] items = + [ + .. evidenceIds + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .Select(id => + { + if (facts.TryGetValue(id, out SemanticC3Fact? fact)) + { + string line = fact.Line is null + ? string.Empty + : ":" + fact.Line.Value; + return Code(id) + " (" + Code(fact.SourcePath + line) + ")"; + } + + if (architectureEvidence.TryGetValue(id, out Evidence? evidence)) + { + string line = evidence.Line is null + ? string.Empty + : ":" + evidence.Line.Value; + return Code(id) + " (" + Code(evidence.RelativePath + line) + ")"; + } + + return Code(id) + " (missing)"; + }), + ]; + + return items.Length == 0 ? "none" : string.Join(", ", items); + } + + private static string MissingSignals( + SemanticC3RelationCandidate relation, + Dictionary facts) + { + if (relation.Status == ReviewStatus.Confirmed) + { + return "none"; + } + + if (relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement) + { + return "confirmed exact external mapping"; + } + + SemanticC3Fact[] relationFacts = + [ + .. relation.EvidenceIds + .Where(facts.ContainsKey) + .Select(id => facts[id]), + ]; + + bool wiring = relationFacts.Any(fact => + fact.Kind is SemanticC3FactKind.ConstructorInjection + or SemanticC3FactKind.EndpointDependency + or SemanticC3FactKind.MethodParameter + or SemanticC3FactKind.DependencyInjectionRegistration + or SemanticC3FactKind.EndpointHandler); + bool invocation = relationFacts.Any(fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation); + + List missing = []; + if (!wiring) + { + missing.Add("DI/handler wiring"); + } + + if (!invocation) + { + missing.Add("direct symbol invocation"); + } + + return missing.Count == 0 + ? "architectural confirmation" + : string.Join(", ", missing); + } + + private static string ReviewSuffix(string? reviewReason) => + string.IsNullOrWhiteSpace(reviewReason) + ? string.Empty + : " Review: " + PlainText(reviewReason); +} diff --git a/src/Repo2C4.Core/Review/EvidenceReportGenerator.cs b/src/Repo2C4.Core/Review/EvidenceReportGenerator.cs index 1b09f912..8cb279e9 100644 --- a/src/Repo2C4.Core/Review/EvidenceReportGenerator.cs +++ b/src/Repo2C4.Core/Review/EvidenceReportGenerator.cs @@ -20,7 +20,7 @@ public sealed record EvidenceReportResult( string Content, EvidenceReportSummary Summary); -public static class EvidenceReportGenerator +public static partial class EvidenceReportGenerator { public const string FileName = "evidence-report.md"; From ba9dadcaf4ac01cd7817a69c26a4558130e53645 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 19:38:08 -0300 Subject: [PATCH 18/74] test: cover semantic C3 relation confidence policy (#66) --- .../SemanticC3RelationBuilderTests.cs | 737 ++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs diff --git a/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs new file mode 100644 index 00000000..ab40e2f4 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs @@ -0,0 +1,737 @@ +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; +using Repo2C4.Core.Review; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3RelationBuilderTests +{ + private const string ProjectPath = "src/App/App.csproj"; + private const string ContainerId = "el_app"; + + [Fact] + public void EndpointToUseCaseToPersistenceIsConfirmedWhenWiringAndInvocationConverge() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3ComponentCandidate persistence = Component( + "T:Sample.OrderRepository", + SemanticC3ComponentCategory.PersistenceAdapter, + "Order repository persistence"); + + SemanticC3SourceSymbolIdentity controllerCtor = + Symbol("M:Sample.OrdersController.#ctor(IOrderService)"); + SemanticC3SourceSymbolIdentity controllerAction = + Symbol("M:Sample.OrdersController.Create()"); + SemanticC3SourceSymbolIdentity serviceCtor = + Symbol("M:Sample.OrderService.#ctor(IOrderRepository)"); + SemanticC3SourceSymbolIdentity serviceMethod = + Symbol("M:Sample.OrderService.Execute()"); + SemanticC3SourceSymbolIdentity program = + Symbol("M:.Program.()"); + + SemanticC3FactSet facts = FactSet( + Fact(controllerCtor, SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", "T:IOrderService:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IOrderService->OrderService:0"), + Fact(controllerAction, SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderService.Execute"), + Fact(serviceCtor, SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", "T:IOrderRepository:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IOrderRepository->OrderRepository:1"), + Fact(serviceMethod, SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderRepository.Save")); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application, persistence), + facts); + + Assert.Equal(2, result.Proposal.Relations.Length); + SemanticC3RelationCandidate endpointRelation = Assert.Single( + result.Proposal.Relations.Where(relation => + relation.SourceComponentId == endpoint.Id)); + SemanticC3RelationCandidate persistenceRelation = Assert.Single( + result.Proposal.Relations.Where(relation => + relation.SourceComponentId == application.Id)); + + Assert.Equal(application.Id, endpointRelation.DestinationId); + Assert.Equal(persistence.Id, persistenceRelation.DestinationId); + Assert.Equal(ReviewStatus.Confirmed, endpointRelation.Status); + Assert.Equal(ReviewStatus.Confirmed, persistenceRelation.Status); + Assert.Contains("Delegates HTTP processing", endpointRelation.Description, StringComparison.Ordinal); + Assert.Contains("for persistence", persistenceRelation.Description, StringComparison.Ordinal); + Assert.Null(endpointRelation.ReviewReason); + Assert.Null(persistenceRelation.ReviewReason); + } + + [Fact] + public void DiWithoutInvocationAndInvocationWithoutDiRemainReviewable() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3SourceSymbolIdentity program = + Symbol("M:.Program.()"); + + SemanticC3FactSet wiringOnly = FactSet( + Fact(Symbol("M:Sample.OrdersController.#ctor(IOrderService)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:IOrderService:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IOrderService->OrderService:0")); + + SemanticC3RelationCandidate wiringRelation = Assert.Single( + SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + wiringOnly).Proposal.Relations); + Assert.Equal(ReviewStatus.RequiresReview, wiringRelation.Status); + Assert.Contains("no direct symbol invocation", wiringRelation.ReviewReason!, StringComparison.Ordinal); + + SemanticC3FactSet invocationOnly = FactSet( + Fact(Symbol("M:Sample.OrdersController.Create()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderService.Execute")); + + SemanticC3RelationCandidate invocationRelation = Assert.Single( + SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + invocationOnly).Proposal.Relations); + Assert.Equal(ReviewStatus.RequiresReview, invocationRelation.Status); + Assert.Contains("without supporting DI", invocationRelation.ReviewReason!, StringComparison.Ordinal); + } + + [Fact] + public void InterfaceImplementationCanResolveInjectedAbstractionWithoutInventingByName() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + + SemanticC3Fact implementation = Fact( + application.SourceSymbol!, + SemanticC3FactKind.ImplementedInterface, + "semantic.symbol.interface", + "I:IOrderService:0"); + SemanticC3Fact injection = Fact( + Symbol("M:Sample.OrdersController.#ctor(IOrderService)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:IOrderService:0"); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + FactSet(implementation, injection)); + + SemanticC3RelationCandidate relation = Assert.Single(result.Proposal.Relations); + Assert.Equal(application.Id, relation.DestinationId); + Assert.Equal(ReviewStatus.RequiresReview, relation.Status); + Assert.Contains(implementation.Id, relation.EvidenceIds); + Assert.Contains(injection.Id, relation.EvidenceIds); + } + + [Fact] + public void WorkerToPublisherIsConfirmedOnlyWithWiringAndInvocation() + { + SemanticC3ComponentCandidate worker = Component( + "T:Sample.OutboxWorker", + SemanticC3ComponentCategory.BackgroundWorker, + "Outbox poller"); + SemanticC3ComponentCandidate publisher = Component( + "T:Sample.RabbitPublisher", + SemanticC3ComponentCategory.MessagingPublisher, + "RabbitMQ publisher adapter"); + SemanticC3SourceSymbolIdentity program = + Symbol("M:.Program.()"); + + SemanticC3FactSet facts = FactSet( + Fact(Symbol("M:Sample.OutboxWorker.#ctor(IEventPublisher)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:IEventPublisher:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Singleton:IEventPublisher->RabbitPublisher:0"), + Fact(Symbol("M:Sample.OutboxWorker.ExecuteAsync()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.RabbitPublisher.Publish")); + + SemanticC3RelationCandidate relation = Assert.Single( + SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(worker, publisher), + facts).Proposal.Relations); + + Assert.Equal(ReviewStatus.Confirmed, relation.Status); + Assert.Equal(publisher.Id, relation.DestinationId); + Assert.Equal("Publishes through RabbitMQ publisher adapter", relation.Description); + } + + [Fact] + public void ConsumerToProcessorToPersistenceCanBeRepresentedWithoutRemoteInference() + { + SemanticC3ComponentCandidate consumer = Component( + "T:Sample.OrderConsumer", + SemanticC3ComponentCategory.MessagingConsumer, + "RabbitMQ consumer adapter"); + SemanticC3ComponentCandidate processor = Component( + "T:Sample.InboxProcessor", + SemanticC3ComponentCategory.ApplicationService, + "Inbox processor"); + SemanticC3ComponentCandidate persistence = Component( + "T:Sample.InboxDbContext", + SemanticC3ComponentCategory.PersistenceAdapter, + "Inbox persistence"); + SemanticC3SourceSymbolIdentity program = + Symbol("M:.Program.()"); + + SemanticC3FactSet facts = FactSet( + Fact(Symbol("M:Sample.OrderConsumer.#ctor(IInboxProcessor)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:IInboxProcessor:0"), + Fact(program, SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IInboxProcessor->InboxProcessor:0"), + Fact(Symbol("M:Sample.OrderConsumer.Handle()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.InboxProcessor.Process"), + Fact(Symbol("M:Sample.InboxProcessor.#ctor(InboxDbContext)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:InboxDbContext:0"), + Fact(Symbol("M:Sample.InboxProcessor.Process()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.InboxDbContext.SaveChanges")); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(consumer, processor, persistence), + facts); + + Assert.Equal(2, result.Proposal.Relations.Length); + Assert.Contains(result.Proposal.Relations, relation => + relation.SourceComponentId == consumer.Id && + relation.DestinationId == processor.Id && + relation.Status == ReviewStatus.Confirmed); + Assert.Contains(result.Proposal.Relations, relation => + relation.SourceComponentId == processor.Id && + relation.DestinationId == persistence.Id && + relation.Status == ReviewStatus.Confirmed); + Assert.All(result.Proposal.Relations, relation => + Assert.Equal(SemanticC3RelationTargetKind.Component, relation.DestinationKind)); + } + + [Fact] + public void ExistingConfirmedExternalRelationIsBoundToTheCorrectAdapterOnly() + { + ArchitectureModel model = BaseModelWithExternal(); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3ComponentCandidate adapter = Component( + "T:Sample.SerasaClient", + SemanticC3ComponentCategory.IntegrationAdapter, + "Serasa integration adapter", + ["ev_external"]); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + model, + Proposal(application, adapter), + FactSet()); + + SemanticC3RelationCandidate relation = Assert.Single(result.Proposal.Relations); + Assert.Equal(adapter.Id, relation.SourceComponentId); + Assert.Equal("el_serasa", relation.DestinationId); + Assert.Equal(SemanticC3RelationTargetKind.ArchitectureElement, relation.DestinationKind); + Assert.Equal(ReviewStatus.Confirmed, relation.Status); + Assert.DoesNotContain(result.Proposal.Relations, item => + item.SourceComponentId == application.Id); + } + + [Fact] + public void HandlerDelegateCanSupplyEndpointOwnershipButStillNeedsInvocationForConfirmation() + { + SemanticC3ComponentCandidate endpoint = Component( + "M:.Program.()", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3SourceSymbolIdentity handler = + Symbol("M:Sample.OrderEndpoints.Handle(IOrderService)"); + SemanticC3SourceSymbolIdentity program = + endpoint.SourceSymbol!; + + SemanticC3Fact handlerFact = Fact( + program, + SemanticC3FactKind.EndpointHandler, + "semantic.host.endpointHandler", + "M:OrderEndpoints.Handle"); + SemanticC3Fact handlerMethod = Fact( + handler, + SemanticC3FactKind.MethodDeclaration, + "semantic.symbol.method", + null); + SemanticC3Fact parameter = Fact( + handler, + SemanticC3FactKind.MethodParameter, + "semantic.symbol.parameter", + "T:IOrderService:0"); + SemanticC3Fact registration = Fact( + Symbol("M:.Program.Configure()"), + SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IOrderService->OrderService:0"); + + SemanticC3RelationCandidate relation = Assert.Single( + SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + FactSet(handlerFact, handlerMethod, parameter, registration)) + .Proposal.Relations); + + Assert.Equal(endpoint.Id, relation.SourceComponentId); + Assert.Equal(application.Id, relation.DestinationId); + Assert.Equal(ReviewStatus.RequiresReview, relation.Status); + Assert.Contains(handlerFact.Id, relation.EvidenceIds); + } + + [Fact] + public void ReciprocalCodeReferencesDoNotFabricateUnsupportedArchitecturalCycle() + { + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3ComponentCandidate adapter = Component( + "T:Sample.ExternalAdapter", + SemanticC3ComponentCategory.IntegrationAdapter, + "External integration adapter"); + + SemanticC3FactSet facts = FactSet( + Fact(Symbol("M:Sample.OrderService.Execute()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.ExternalAdapter.Call"), + Fact(Symbol("M:Sample.ExternalAdapter.Callback()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderService.Handle")); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(application, adapter), + facts); + + SemanticC3RelationCandidate relation = Assert.Single(result.Proposal.Relations); + Assert.Equal(application.Id, relation.SourceComponentId); + Assert.Equal(adapter.Id, relation.DestinationId); + Assert.Equal(ReviewStatus.RequiresReview, relation.Status); + Assert.DoesNotContain(result.Proposal.Relations, item => + item.SourceComponentId == adapter.Id && + item.DestinationId == application.Id); + } + + [Fact] + public void DuplicateSignalsCollapseIntoOneDeterministicEdge() + { + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3ComponentCandidate persistence = Component( + "T:Sample.OrderRepository", + SemanticC3ComponentCategory.PersistenceAdapter, + "Order persistence"); + + SemanticC3Fact first = Fact( + Symbol("M:Sample.OrderService.Execute()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderRepository.Save"); + SemanticC3Fact second = Fact( + Symbol("M:Sample.OrderService.Retry()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderRepository.Save"); + + SemanticC3RelationBuildResult forward = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(application, persistence), + FactSet(first, second)); + SemanticC3RelationBuildResult reverse = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(application, persistence), + FactSet(second, first)); + + SemanticC3RelationCandidate relation = Assert.Single(forward.Proposal.Relations); + Assert.Equal(2, relation.EvidenceIds.Length); + Assert.Equal( + SemanticC3ContractJson.Serialize(forward.Proposal), + SemanticC3ContractJson.Serialize(reverse.Proposal)); + } + + [Fact] + public void ProjectReferenceAloneNeverCreatesConfirmedRuntimeRelation() + { + ArchitectureModel model = BaseModel(); + Evidence projectReference = new( + "ev_project_ref", + "dotnet.project.reference", + ProjectPath, + 1, + EvidenceSourceType.ProjectFile, + "Project reference exists."); + model = model with + { + Snapshot = model.Snapshot with + { + Evidence = [.. model.Snapshot.Evidence, projectReference], + }, + }; + + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service", + ["ev_project_ref"]); + SemanticC3ComponentCandidate persistence = Component( + "T:Sample.OrderRepository", + SemanticC3ComponentCategory.PersistenceAdapter, + "Order persistence"); + + SemanticC3RelationBuildResult result = SemanticC3RelationBuilder.Build( + model, + Proposal(application, persistence), + FactSet()); + + Assert.Empty(result.Proposal.Relations); + } + + [Fact] + public void RelationBudgetTruncatesDeterministicallyAndReportsDiagnostic() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + SemanticC3ComponentCandidate persistence = Component( + "T:Sample.OrderRepository", + SemanticC3ComponentCategory.PersistenceAdapter, + "Order persistence"); + + SemanticC3FactSet facts = FactSet( + Fact(Symbol("M:Sample.OrdersController.Create()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderService.Execute"), + Fact(Symbol("M:Sample.OrderService.Execute()"), + SemanticC3FactKind.SymbolInvocation, + "semantic.collaboration.staticInvocation", + "M:Sample.OrderRepository.Save")); + + SemanticC3RelationBuildOptions options = new() + { + MaxComponents = 3, + MaxRelations = 1, + }; + + SemanticC3RelationBuildResult first = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application, persistence), + facts, + options); + SemanticC3RelationBuildResult repeated = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application, persistence), + facts, + options); + + Assert.Single(first.Proposal.Relations); + Assert.Contains(first.Diagnostics, diagnostic => + diagnostic.Code == "semanticC3.relationBudget"); + Assert.Equal( + SemanticC3ContractJson.Serialize(first.Proposal), + SemanticC3ContractJson.Serialize(repeated.Proposal)); + } + + [Fact] + public void SemanticEvidenceReportExplainsSignalsMissingSignalsAndReviewReasons() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + + SemanticC3Fact injection = Fact( + Symbol("M:Sample.OrdersController.#ctor(IOrderService)"), + SemanticC3FactKind.ConstructorInjection, + "semantic.wiring.constructorInjection", + "T:IOrderService:0"); + SemanticC3Fact registration = Fact( + Symbol("M:.Program.()"), + SemanticC3FactKind.DependencyInjectionRegistration, + "semantic.wiring.diRegistration", + "DI:Scoped:IOrderService->OrderService:0"); + SemanticC3FactSet facts = FactSet(injection, registration); + + SemanticC3RelationBuildResult graph = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + facts); + EvidenceReportResult report = EvidenceReportGenerator.GenerateSemanticC3( + BaseModel(), + graph, + facts); + + Assert.Equal("semantic-c3-evidence-report.md", report.FileName); + Assert.Contains("## Component candidates", report.Content, StringComparison.Ordinal); + Assert.Contains("## Relations", report.Content, StringComparison.Ordinal); + Assert.Contains("semantic.wiring.constructorInjection", report.Content, StringComparison.Ordinal); + Assert.Contains("missing signals: direct symbol invocation", report.Content, StringComparison.Ordinal); + Assert.Contains("Review:", report.Content, StringComparison.Ordinal); + Assert.DoesNotContain("C:\\", report.Content, StringComparison.Ordinal); + Assert.DoesNotContain("/home/", report.Content, StringComparison.Ordinal); + } + + private static SemanticC3Proposal Proposal( + params SemanticC3ComponentCandidate[] components) => + new( + SemanticC3ContractSchema.Version, + [ContainerId], + [.. components], + []); + + private static SemanticC3ComponentCandidate Component( + string symbolId, + SemanticC3ComponentCategory category, + string name, + string[]? evidenceIds = null) + { + SemanticC3SourceSymbolIdentity symbol = Symbol(symbolId); + return new SemanticC3ComponentCandidate( + StableIds.ForSemanticC3Component( + ContainerId, + category, + symbol.Id), + ContainerId, + category, + name, + "Test responsibility.", + evidenceIds is null ? ["fact_component_" + symbol.Id] : [.. evidenceIds], + symbol, + ReviewStatus.RequiresReview, + "Candidate boundary requires architectural review."); + } + + private static SemanticC3SourceSymbolIdentity Symbol(string symbolId) => + new( + StableIds.ForSemanticC3SourceSymbol(ProjectPath, symbolId), + ProjectPath, + symbolId); + + private static SemanticC3Fact Fact( + SemanticC3SourceSymbolIdentity source, + SemanticC3FactKind kind, + string category, + string? related) => + new( + StableIds.ForSemanticC3Fact( + ProjectPath, + SourcePath(source.SymbolId), + source.Id, + kind, + category, + related ?? string.Empty), + ProjectPath, + SourcePath(source.SymbolId), + 1, + source, + kind, + category, + "Bounded structural test fact.", + related); + + private static string SourcePath(string symbolId) + { + if (symbolId.Contains("OrdersController", StringComparison.Ordinal)) + { + return "src/App/OrdersController.cs"; + } + + if (symbolId.Contains("OrderRepository", StringComparison.Ordinal) || + symbolId.Contains("InboxDbContext", StringComparison.Ordinal)) + { + return "src/App/Persistence.cs"; + } + + if (symbolId.Contains("RabbitPublisher", StringComparison.Ordinal) || + symbolId.Contains("ExternalAdapter", StringComparison.Ordinal)) + { + return "src/App/Adapters.cs"; + } + + if (symbolId.Contains("OrderConsumer", StringComparison.Ordinal)) + { + return "src/App/Consumer.cs"; + } + + return "src/App/Program.cs"; + } + + private static SemanticC3FactSet FactSet(params SemanticC3Fact[] facts) => + new( + SemanticC3ContractSchema.Version, + [.. facts], + []); + + private static ArchitectureModel BaseModel() + { + RepositoryFile[] files = + [ + new("Repo.slnx", 1, null), + new(ProjectPath, 1, null), + new("src/App/Program.cs", 1, null), + new("src/App/OrdersController.cs", 1, null), + new("src/App/Persistence.cs", 1, null), + new("src/App/Adapters.cs", 1, null), + new("src/App/Consumer.cs", 1, null), + ]; + Evidence[] evidence = + [ + new( + "ev_system", + "solution", + "Repo.slnx", + 1, + EvidenceSourceType.Manifest, + "Solution exists."), + new( + "ev_app_project", + "dotnet.project", + ProjectPath, + 1, + EvidenceSourceType.ProjectFile, + "Application project exists."), + ]; + + return new ArchitectureModel( + ContractSchema.Version, + ArchitectureLevel.C2, + new RepositorySnapshot( + ContractSchema.Version, + "semantic-relations-test", + [.. files], + [.. evidence], + []), + [ + new ArchitectureElement( + "el_system", + ArchitectureElementKind.SoftwareSystem, + "Orders", + null, + ["ev_system"], + ReviewStatus.Confirmed, + null), + new ArchitectureElement( + ContainerId, + ArchitectureElementKind.Container, + "Application", + "el_system", + ["ev_app_project"], + ReviewStatus.Confirmed, + null), + ], + []); + } + + private static ArchitectureModel BaseModelWithExternal() + { + ArchitectureModel model = BaseModel(); + Evidence externalEvidence = new( + "ev_external", + "external.http.outbound", + "src/App/Adapters.cs", + 1, + EvidenceSourceType.SourceCode, + "HTTP dependency exists."); + ArchitectureElement external = new( + "el_serasa", + ArchitectureElementKind.SoftwareSystem, + "Serasa", + null, + ["ev_external"], + ReviewStatus.Confirmed, + null); + ArchitectureRelation relation = new( + StableIds.ForRelation( + ContainerId, + external.Id, + "serasa-http"), + ContainerId, + external.Id, + "Calls Serasa via HTTP", + ["ev_external"], + ReviewStatus.Confirmed, + null); + + return model with + { + Snapshot = model.Snapshot with + { + Evidence = [.. model.Snapshot.Evidence, externalEvidence], + Files = + [ + .. model.Snapshot.Files, + new RepositoryFile("src/App/Adapters.cs", 1, null), + ], + }, + Elements = [.. model.Elements, external], + Relations = [relation], + }; + } +} From bf97dae32a10caa91ffffde5c161275a1429ac90 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 19:38:55 -0300 Subject: [PATCH 19/74] fix: harden semantic C3 relation composition (#66) --- .../C3/SemanticC3RelationBuilder.cs | 30 +++++++++++++------ .../SemanticC3RelationBuilderTests.cs | 5 ---- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs index 9b86e0cb..19389653 100644 --- a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs +++ b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs @@ -200,8 +200,7 @@ .. normalized SemanticC3Proposal result = new( SemanticC3ContractSchema.Version, [ - .. components - .Select(component => component.ContainerId) + .. proposal.SelectedContainerIds .Distinct(StringComparer.Ordinal) .OrderBy(id => id, StringComparer.Ordinal), ], @@ -989,19 +988,32 @@ private sealed class ComponentIndex(SemanticC3ComponentCandidate[] components) private readonly SemanticC3ComponentCandidate[] _components = components; public SemanticC3ComponentCandidate? FindExact( - SemanticC3SourceSymbolIdentity sourceSymbol) => - _components.SingleOrDefault(component => - component.SourceSymbol?.Id == sourceSymbol.Id); + SemanticC3SourceSymbolIdentity sourceSymbol) + { + SemanticC3ComponentCandidate[] matches = + [ + .. _components.Where(component => + component.SourceSymbol?.Id == sourceSymbol.Id), + ]; + return matches.Length == 1 ? matches[0] : null; + } public SemanticC3ComponentCandidate? FindByMethodOwner( SemanticC3SourceSymbolIdentity methodSymbol) { string? declaringType = DeclaringTypeId(methodSymbol.SymbolId); - return declaringType is null - ? FindExact(methodSymbol) - : _components.SingleOrDefault(component => + if (declaringType is null) + { + return FindExact(methodSymbol); + } + + SemanticC3ComponentCandidate[] matches = + [ + .. _components.Where(component => component.SourceSymbol?.ProjectPath == methodSymbol.ProjectPath && - component.SourceSymbol.SymbolId == declaringType); + component.SourceSymbol.SymbolId == declaringType), + ]; + return matches.Length == 1 ? matches[0] : null; } public SemanticC3ComponentCandidate? FindByTypeName(string typeName) diff --git a/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs index ab40e2f4..918f54c3 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs @@ -724,11 +724,6 @@ private static ArchitectureModel BaseModelWithExternal() Snapshot = model.Snapshot with { Evidence = [.. model.Snapshot.Evidence, externalEvidence], - Files = - [ - .. model.Snapshot.Files, - new RepositoryFile("src/App/Adapters.cs", 1, null), - ], }, Elements = [.. model.Elements, external], Relations = [relation], From 747b2bbdb11619f0f8ddea23e7b6265584d4e3ca Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 19:42:33 -0300 Subject: [PATCH 20/74] docs: finalize semantic C3 relation confidence policy (#66) --- CHANGELOG.md | 1 + docs/c4-mapping.md | 19 +++++ .../C3/SemanticC3RelationBuilder.cs | 25 +++++++ .../SemanticC3RelationBuilderTests.cs | 72 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adda9019..795a5cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Bounded Semantic C3 internal/external relation composition with explicit wiring+invocation confidence policy, deterministic edge deduplication, weak-cycle/cross-container safeguards, existing C1/C2 peer reuse, and a signal/missing-signal evidence report. - Reviewable Semantic C3 worker/messaging/persistence proposals that merge hosted-service evidence, distinguish supported publish/consume adapters through Integration Evidence, require observed use before promoting repository-shaped persistence, reuse existing C1/C2 external peers, and keep weak/unlinked evidence conservative. - Reviewable Semantic C3 HTTP/application proposals that aggregate routes by semantic boundary and distinguish DI-/handler-backed application services from name-only classes. - Bounded, cancellation-aware C# structural fact extraction for Semantic C3, covering symbols, DI/wiring, Minimal API/controllers, hosted services, DbContext, messaging role signals and conservative intra-project collaboration facts without build/code execution or source/literal propagation. diff --git a/docs/c4-mapping.md b/docs/c4-mapping.md index e9a9aa09..33f8f6de 100644 --- a/docs/c4-mapping.md +++ b/docs/c4-mapping.md @@ -153,6 +153,25 @@ The asynchronous Semantic C3 pass combines local .NET structure with the normali - Low/medium confidence and all newly proposed C3 boundaries remain explicit `requiresReview` assertions. Resource, contract and provider detail remain provenance/relation metadata rather than extra C4 components. - General component-to-component collaboration (worker → publisher, worker → application processing, application → persistence, and similar internal flow) is intentionally deferred to the dedicated internal-relation policy. +## Semantic C3 internal-relation confidence policy + +Semantic C3 relations are composed only after component discovery. The relation builder is deterministic and bounded by explicit `MaxComponents` and `MaxRelations` limits. + +| Structural evidence | Relation status | +| --- | --- | +| DI/handler wiring **and** direct symbol invocation between supported component responsibilities | `confirmed` | +| DI/handler wiring without direct invocation | `requiresReview` | +| direct invocation without DI/handler wiring | `requiresReview` | +| interface implementation only, type/name similarity only, or `ProjectReference` only | no runtime relation | +| existing exact C1/C2 external relation already `confirmed` with matching provenance | component → existing external element may be `confirmed` | +| external C1/C2 relation still under review | component → existing external element remains `requiresReview` | + +Supported internal responsibility flows are intentionally narrow: HTTP endpoint → application service, application service → persistence/integration adapter, background worker → messaging publisher, and messaging consumer → application service. Other static references do not become architecture merely because they exist in the source graph. + +Relations are merged by semantic source/destination/kind/description and evidence is unioned deterministically. Self-loops without explicit architectural semantics, missing endpoints and component-to-component edges that cross container boundaries are omitted. Reciprocal weak references do not manufacture cycles; a cycle is retained only when independently strong supported relations justify both directions. + +The Semantic C3 evidence report lists each candidate/relation, its evidence locations and signal categories, missing strong signals, review reason and graph-budget/omission diagnostics without reproducing source bodies or configuration values. + ## Out of scope The C1/C2 mapping policy itself does not implement LikeC4 emission, CLI/MCP/Agent orchestration, AI calls, runtime tracing or automatic PR generation. The additive Semantic C3 policies above only define conservative component proposals; generation/orchestration is handled by later phases. diff --git a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs index 19389653..d91ea5c4 100644 --- a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs +++ b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs @@ -465,6 +465,31 @@ private static IEnumerable NormalizeExistingRelatio "An existing Semantic C3 external relation was omitted because its C1/C2 element does not exist.")); continue; } + else + { + ArchitectureRelation[] backingRelations = + [ + .. baseModel.Relations.Where(item => + (item.SourceId == source.ContainerId && + item.DestinationId == relation.DestinationId) || + (item.DestinationId == source.ContainerId && + item.SourceId == relation.DestinationId)) + .Where(item => + item.EvidenceIds.Intersect( + relation.EvidenceIds, + StringComparer.Ordinal).Any()), + ]; + + if (backingRelations.Any(item => item.Status == ReviewStatus.Confirmed)) + { + yield return relation with + { + Status = ReviewStatus.Confirmed, + ReviewReason = null, + }; + continue; + } + } yield return relation; } diff --git a/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs index 918f54c3..28d9b5e6 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3RelationBuilderTests.cs @@ -282,6 +282,41 @@ public void ExistingConfirmedExternalRelationIsBoundToTheCorrectAdapterOnly() item.SourceComponentId == application.Id); } + [Fact] + public void ExistingReviewExternalRelationIsPromotedWhenConfirmedC1C2EvidenceMatches() + { + ArchitectureModel model = BaseModelWithExternal(); + SemanticC3ComponentCandidate adapter = Component( + "T:Sample.SerasaClient", + SemanticC3ComponentCategory.IntegrationAdapter, + "Serasa integration adapter"); + + SemanticC3RelationCandidate existing = new( + StableIds.ForRelation(adapter.Id, "el_serasa", "legacy-c3-external"), + adapter.Id, + "el_serasa", + SemanticC3RelationTargetKind.ArchitectureElement, + "Calls Serasa via HTTP", + ["ev_external"], + ReviewStatus.RequiresReview, + "C3 association pending review."); + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [ContainerId], + [adapter], + [existing]); + + SemanticC3RelationCandidate relation = Assert.Single( + SemanticC3RelationBuilder.Build( + model, + proposal, + FactSet()).Proposal.Relations); + + Assert.Equal(ReviewStatus.Confirmed, relation.Status); + Assert.Null(relation.ReviewReason); + } + [Fact] public void HandlerDelegateCanSupplyEndpointOwnershipButStillNeedsInvocationForConfirmation() { @@ -495,6 +530,43 @@ public void RelationBudgetTruncatesDeterministicallyAndReportsDiagnostic() SemanticC3ContractJson.Serialize(repeated.Proposal)); } + [Fact] + public void ComponentBudgetTruncatesDeterministically() + { + SemanticC3ComponentCandidate endpoint = Component( + "T:Sample.OrdersController", + SemanticC3ComponentCategory.HttpEndpoint, + "Orders HTTP endpoint"); + SemanticC3ComponentCandidate application = Component( + "T:Sample.OrderService", + SemanticC3ComponentCategory.ApplicationService, + "Order application service"); + + SemanticC3RelationBuildOptions options = new() + { + MaxComponents = 1, + MaxRelations = 10, + }; + + SemanticC3RelationBuildResult first = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + FactSet(), + options); + SemanticC3RelationBuildResult repeated = SemanticC3RelationBuilder.Build( + BaseModel(), + Proposal(endpoint, application), + FactSet(), + options); + + Assert.Single(first.Proposal.Components); + Assert.Contains(first.Diagnostics, diagnostic => + diagnostic.Code == "semanticC3.componentBudget"); + Assert.Equal( + SemanticC3ContractJson.Serialize(first.Proposal), + SemanticC3ContractJson.Serialize(repeated.Proposal)); + } + [Fact] public void SemanticEvidenceReportExplainsSignalsMissingSignalsAndReviewReasons() { From 019db94be43280fba31db48155e1e541592e84de Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:13:28 -0300 Subject: [PATCH 21/74] feat: add canonical multi-container C3 workspace (#67) --- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 137 ++++++++++++++-- .../Contracts/ArchitectureC3Contracts.cs | 14 ++ .../Contracts/ArchitectureC3Validator.cs | 147 ++++++++++++++++++ 3 files changed, 286 insertions(+), 12 deletions(-) diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index 7bb8d4d6..ddfb0996 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -3,7 +3,7 @@ namespace Repo2C4.Core.C3; -/// Builds a bounded, evidence-first C3 proposal for one explicitly selected container. +/// Builds bounded, evidence-first C3 proposals for explicitly selected C2 containers. public static class ArchitectureC3Builder { private static readonly (string Prefix, string Name, string Responsibility)[] Categories = @@ -13,30 +13,138 @@ private static readonly (string Prefix, string Name, string Responsibility)[] Ca ("dotnet.project.reference", "Application dependency", "Represents a build-time dependency that may indicate an application-layer collaboration."), ]; - public static ArchitectureC3Model Build(ArchitectureModel baseModel, string selectedContainerId) + public static ArchitectureC3Model Build( + ArchitectureModel baseModel, + string selectedContainerId) { - ArgumentNullException.ThrowIfNull(baseModel); ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); - ImmutableArray baseErrors = ContractValidator.ValidateModel(baseModel); + ArchitectureC3Workspace workspace = BuildMany( + baseModel, + [selectedContainerId]); + + ArchitectureC3Selection selection = workspace.Selections[0]; + return new ArchitectureC3Model( + workspace.SchemaVersion, + workspace.BaseModel, + selection.SelectedContainerId, + selection.Components, + selection.Relations); + } + + public static ArchitectureC3Workspace BuildMany( + ArchitectureModel baseModel, + IEnumerable selectedContainerIds) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentNullException.ThrowIfNull(selectedContainerIds); + + ImmutableArray baseErrors = + ContractValidator.ValidateModel(baseModel); if (!baseErrors.IsEmpty) { throw new ContractValidationException(baseErrors); } - ArchitectureElement? selected = baseModel.Elements - .FirstOrDefault(item => item.Id == selectedContainerId && item.Kind == ArchitectureElementKind.Container); - if (selected is null) + string[] requested = [.. selectedContainerIds]; + if (requested.Length == 0) + { + return new ArchitectureC3Workspace( + ContractSchema.Version, + baseModel, + []); + } + + if (baseModel.Level != ArchitectureLevel.C2) { throw new ContractValidationException( [ new ContractError( - "c3.containerMissing", - "$.selectedContainerId", - "Selected C3 container must exist in the C2 base model."), + "c3.baseLevel", + "$.baseModel.level", + "C3 requires a C2 base model."), ]); } + List selectionErrors = []; + Dictionary elements = baseModel.Elements + .ToDictionary(item => item.Id, StringComparer.Ordinal); + + for (int i = 0; i < requested.Length; i++) + { + string? id = requested[i]; + string path = "$.selectedContainerIds[" + i + "]"; + + if (string.IsNullOrWhiteSpace(id)) + { + selectionErrors.Add(new ContractError( + "c3.containerMissing", + path, + "Selected C3 container ID is required.")); + continue; + } + + if (!elements.TryGetValue(id, out ArchitectureElement? element)) + { + selectionErrors.Add(new ContractError( + "c3.containerMissing", + path, + "Selected C3 container must exist in the C2 base model.")); + continue; + } + + if (element.Kind != ArchitectureElementKind.Container) + { + selectionErrors.Add(new ContractError( + "c3.containerKind", + path, + "Selected C3 element must be a C2 container.")); + } + } + + if (selectionErrors.Count > 0) + { + throw new ContractValidationException([.. selectionErrors]); + } + + string[] canonicalIds = + [ + .. requested + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + + ArchitectureC3Selection[] selections = + [ + .. canonicalIds.Select(id => + { + ArchitectureC3Model model = BuildSingle(baseModel, elements[id]); + return new ArchitectureC3Selection( + model.SelectedContainerId, + model.Components, + model.Relations); + }), + ]; + + ArchitectureC3Workspace workspace = new( + ContractSchema.Version, + baseModel, + [.. selections]); + + ImmutableArray errors = + ArchitectureC3WorkspaceValidator.Validate(workspace); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return workspace; + } + + private static ArchitectureC3Model BuildSingle( + ArchitectureModel baseModel, + ArchitectureElement selected) + { Dictionary evidenceById = baseModel.Snapshot.Evidence .ToDictionary(item => item.Id, StringComparer.Ordinal); Evidence[] directEvidence = @@ -91,14 +199,19 @@ [.. matching.Select(item => item.Id)], .OrderBy(item => item.Id, StringComparer.Ordinal)) { ArchitectureComponent? component = components.FirstOrDefault(candidate => - candidate.EvidenceIds.Intersect(baseRelation.EvidenceIds, StringComparer.Ordinal).Any()); + candidate.EvidenceIds.Intersect( + baseRelation.EvidenceIds, + StringComparer.Ordinal).Any()); if (component is null) { continue; } relations.Add(new ArchitectureComponentRelation( - StableIds.ForRelation(component.Id, baseRelation.DestinationId, "c3|" + baseRelation.Id), + StableIds.ForRelation( + component.Id, + baseRelation.DestinationId, + "c3|" + baseRelation.Id), component.Id, baseRelation.DestinationId, baseRelation.Description, diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Contracts.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Contracts.cs index b4e18a45..ba23e352 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Contracts.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Contracts.cs @@ -29,3 +29,17 @@ public sealed record ArchitectureC3Model( string SelectedContainerId, ImmutableArray Components, ImmutableArray Relations); + +/// One selected C2 container and its bounded C3 extension inside a shared workspace. +public sealed record ArchitectureC3Selection( + string SelectedContainerId, + ImmutableArray Components, + ImmutableArray Relations); + +/// +/// Canonical multi-container C3 workspace. All selections share exactly one reviewed C1/C2 base model/snapshot. +/// +public sealed record ArchitectureC3Workspace( + string SchemaVersion, + ArchitectureModel BaseModel, + ImmutableArray Selections); diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index 5a0edfd0..6aad0d8f 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -227,3 +227,150 @@ private static void ValidateProvenance( } } } + + +/// Validates multiple C3 selections against one shared base model and global C3 identity space. +public static class ArchitectureC3WorkspaceValidator +{ + public static ImmutableArray Validate(ArchitectureC3Workspace? workspace) + { + List errors = []; + + if (workspace is null) + { + errors.Add(new ContractError("c3.workspaceMissing", "$", "C3 workspace is required.")); + return [.. errors]; + } + + if (workspace.SchemaVersion != ContractSchema.Version) + { + errors.Add(new ContractError( + "schema.unsupported", + "$.schemaVersion", + "Only schema version " + ContractSchema.Version + " is supported.")); + } + + ImmutableArray baseErrors = ContractValidator.ValidateModel(workspace.BaseModel); + errors.AddRange(baseErrors.Select(error => + error with { Path = "$.baseModel" + error.Path.TrimStart('$') })); + if (!baseErrors.IsEmpty) + { + return [.. errors]; + } + + if (workspace.Selections.IsDefault) + { + errors.Add(new ContractError( + "collection.missing", + "$.selections", + "C3 selections must be an initialized collection.")); + return [.. errors]; + } + + if (!workspace.Selections.IsEmpty && + workspace.BaseModel.Level != ArchitectureLevel.C2) + { + errors.Add(new ContractError( + "c3.baseLevel", + "$.baseModel.level", + "C3 requires a C2 base model.")); + } + + HashSet selectedContainerIds = new(StringComparer.Ordinal); + HashSet componentIds = new(StringComparer.Ordinal); + HashSet relationIds = new(StringComparer.Ordinal); + + for (int i = 0; i < workspace.Selections.Length; i++) + { + ArchitectureC3Selection? selection = workspace.Selections[i]; + string path = "$.selections[" + i + "]"; + + if (selection is null) + { + errors.Add(new ContractError( + "c3.selectionMissing", + path, + "C3 selection must not be null.")); + continue; + } + + if (string.IsNullOrWhiteSpace(selection.SelectedContainerId)) + { + errors.Add(new ContractError( + "c3.containerMissing", + path + ".selectedContainerId", + "Selected C3 container ID is required.")); + } + else if (!selectedContainerIds.Add(selection.SelectedContainerId)) + { + errors.Add(new ContractError( + "c3.selectionDuplicate", + path + ".selectedContainerId", + "A C3 container may appear only once in the canonical workspace.")); + } + + ArchitectureC3Model model = new( + workspace.SchemaVersion, + workspace.BaseModel, + selection.SelectedContainerId, + selection.Components, + selection.Relations); + + foreach (ContractError error in ArchitectureC3Validator.Validate(model)) + { + if (error.Path.StartsWith("$.baseModel", StringComparison.Ordinal) || + error.Code == "schema.unsupported" || + error.Code == "c3.baseLevel") + { + continue; + } + + errors.Add(error with + { + Path = path + error.Path.TrimStart('$'), + }); + } + + if (selection.Components.IsDefault || selection.Relations.IsDefault) + { + continue; + } + + for (int componentIndex = 0; componentIndex < selection.Components.Length; componentIndex++) + { + ArchitectureComponent? component = selection.Components[componentIndex]; + if (component is null || string.IsNullOrWhiteSpace(component.Id)) + { + continue; + } + + if (!componentIds.Add(component.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".components[" + componentIndex + "].id", + "C3 component IDs must be unique across all selected containers.")); + } + } + + for (int relationIndex = 0; relationIndex < selection.Relations.Length; relationIndex++) + { + ArchitectureComponentRelation? relation = selection.Relations[relationIndex]; + if (relation is null || string.IsNullOrWhiteSpace(relation.Id)) + { + continue; + } + + if (!relationIds.Add(relation.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".relations[" + relationIndex + "].id", + "C3 relation IDs must be unique across all selected containers.")); + } + } + } + + return [.. errors]; + } +} From ecfc2f255c11b1751f872e48699effdb741ae6c0 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:14:44 -0300 Subject: [PATCH 22/74] feat: emit one multi-container C3 workspace (#67) --- src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs | 271 ++++++++++++++++++----- 1 file changed, 218 insertions(+), 53 deletions(-) diff --git a/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs b/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs index 80ed9ca4..e39027ff 100644 --- a/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs +++ b/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs @@ -13,6 +13,7 @@ public static class LikeC4Emitter public const string SpecificationFileName = "specification.c4"; public const string ModelFileName = "model.c4"; public const string ViewsFileName = "views.c4"; + public const string C3ViewsFileName = "c3.views.c4"; private const string RequiresReviewTag = "requires-review"; @@ -37,7 +38,6 @@ public static ImmutableArray Emit(ArchitectureModel model) ]; } - public static ImmutableArray EmitWithC3( ArchitectureModel baseModel, ArchitectureC3Model c3) @@ -45,58 +45,173 @@ public static ImmutableArray EmitWithC3( ArgumentNullException.ThrowIfNull(baseModel); ArgumentNullException.ThrowIfNull(c3); - ImmutableArray baseErrors = ContractValidator.ValidateModel(baseModel); ImmutableArray c3Errors = ArchitectureC3Validator.Validate(c3); - if (!baseErrors.IsEmpty || !c3Errors.IsEmpty) + if (!c3Errors.IsEmpty) { - throw new ContractValidationException([.. baseErrors, .. c3Errors]); + throw new ContractValidationException(c3Errors); } - if (c3.Components.IsEmpty) - { - throw new ContractValidationException( + ArchitectureC3Workspace workspace = new( + c3.SchemaVersion, + baseModel, [ - new ContractError( - "c3.insufficientEvidence", - "$.components", - "Selected container has insufficient evidence for a C3 proposal."), + new ArchitectureC3Selection( + c3.SelectedContainerId, + c3.Components, + c3.Relations), ]); + + return EmitWithC3(workspace); + } + + public static ImmutableArray EmitWithC3( + ArchitectureC3Workspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + + ImmutableArray errors = + ArchitectureC3WorkspaceValidator.Validate(workspace); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); } - Dictionary localIdentifiers = BuildLocalIdentifiers(baseModel.Elements); - Dictionary references = BuildReferences(baseModel.Elements, localIdentifiers); - Dictionary componentIdentifiers = new(StringComparer.Ordinal); + if (workspace.Selections.IsEmpty) + { + return Emit(workspace.BaseModel); + } - foreach (ArchitectureComponent component in c3.Components.OrderBy(item => item.Id, StringComparer.Ordinal)) + for (int i = 0; i < workspace.Selections.Length; i++) { - string normalized = NormalizeIdentifier(component.Id); - if (componentIdentifiers.Values.Contains(normalized, StringComparer.Ordinal)) + if (workspace.Selections[i].Components.IsEmpty) { throw new ContractValidationException( [ new ContractError( - "likec4.identifierCollision", - "$.components", - "C3 component IDs normalize to the same LikeC4 identifier."), + "c3.insufficientEvidence", + "$.selections[" + i + "].components", + "Selected container has insufficient evidence for a C3 proposal."), ]); } - - componentIdentifiers.Add(component.Id, normalized); - references.Add(component.Id, references[c3.SelectedContainerId] + "." + normalized); } + ArchitectureModel baseModel = workspace.BaseModel; + ArchitectureC3Selection[] selections = + [ + .. workspace.Selections + .OrderBy(selection => selection.SelectedContainerId, StringComparer.Ordinal), + ]; + + Dictionary localIdentifiers = BuildLocalIdentifiers(baseModel.Elements); + Dictionary references = BuildReferences(baseModel.Elements, localIdentifiers); + Dictionary componentIdentifiers = BuildComponentIdentifiers( + selections, + references); + Dictionary viewIdentifiers = BuildC3ViewIdentifiers(selections); + return [ new LikeC4GeneratedFile(SpecificationFileName, EmitSpecification(includeComponent: true)), new LikeC4GeneratedFile( ModelFileName, - EmitModelWithC3(baseModel, c3, localIdentifiers, componentIdentifiers, references)), + EmitModelWithC3( + baseModel, + selections, + localIdentifiers, + componentIdentifiers, + references)), new LikeC4GeneratedFile(ViewsFileName, EmitViews(baseModel, references)), - new LikeC4GeneratedFile("c3.views.c4", EmitC3View(baseModel, c3, references)), + new LikeC4GeneratedFile( + C3ViewsFileName, + EmitC3Views( + baseModel, + selections, + references, + viewIdentifiers)), ]; } - private static Dictionary BuildLocalIdentifiers(ImmutableArray elements) + private static Dictionary BuildComponentIdentifiers( + ArchitectureC3Selection[] selections, + Dictionary references) + { + Dictionary componentIdentifiers = new(StringComparer.Ordinal); + HashSet occupiedLocalIdentifiers = new(StringComparer.Ordinal); + + foreach (ArchitectureC3Selection selection in selections) + { + foreach (ArchitectureComponent component in selection.Components + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + string normalized = NormalizeIdentifier(component.Id); + string collisionKey = selection.SelectedContainerId + "\u001f" + normalized; + if (!occupiedLocalIdentifiers.Add(collisionKey)) + { + throw new ContractValidationException( + [ + new ContractError( + "likec4.identifierCollision", + "$.selections", + "C3 component IDs normalize to the same LikeC4 identifier in one container."), + ]); + } + + componentIdentifiers.Add(component.Id, normalized); + references.Add( + component.Id, + references[selection.SelectedContainerId] + "." + normalized); + } + } + + return componentIdentifiers; + } + + private static Dictionary BuildC3ViewIdentifiers( + ArchitectureC3Selection[] selections) + { + Dictionary identifiers = new(StringComparer.Ordinal); + if (selections.Length == 1) + { + identifiers.Add(selections[0].SelectedContainerId, "c3"); + return identifiers; + } + + Dictionary groups = selections + .GroupBy( + selection => "c3_" + NormalizeIdentifier(selection.SelectedContainerId), + StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group + .OrderBy(selection => selection.SelectedContainerId, StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + foreach ((string baseIdentifier, ArchitectureC3Selection[] group) in groups + .OrderBy(item => item.Key, StringComparer.Ordinal)) + { + foreach (ArchitectureC3Selection selection in group) + { + string identifier = group.Length == 1 + ? baseIdentifier + : baseIdentifier + "_" + StableViewSuffix(selection.SelectedContainerId); + identifiers.Add(selection.SelectedContainerId, identifier); + } + } + + return identifiers; + } + + private static string StableViewSuffix(string containerId) + { + string stable = StableIds.ForElement( + ArchitectureElementKind.Container, + "c3-view|" + containerId); + return stable["el_".Length..("el_".Length + 8)]; + } + + private static Dictionary BuildLocalIdentifiers( + ImmutableArray elements) { Dictionary localIdentifiers = new(StringComparer.Ordinal); Dictionary occupiedNames = new(StringComparer.Ordinal); @@ -296,14 +411,21 @@ private static void EmitMetadata( if (!evidenceIds.IsEmpty) { - string values = string.Join(", ", evidenceIds.OrderBy(id => id, StringComparer.Ordinal).Select(Quote)); + string values = string.Join( + ", ", + evidenceIds + .OrderBy(id => id, StringComparer.Ordinal) + .Select(Quote)); AppendLine(builder, indent + 1, "evidenceIds [" + values + "]"); } AppendLine( builder, indent + 1, - "reviewStatus " + Quote(status == ReviewStatus.Confirmed ? "confirmed" : "requiresReview")); + "reviewStatus " + Quote( + status == ReviewStatus.Confirmed + ? "confirmed" + : "requiresReview")); if (reviewReason is not null) { @@ -315,7 +437,7 @@ private static void EmitMetadata( private static string EmitModelWithC3( ArchitectureModel model, - ArchitectureC3Model c3, + ArchitectureC3Selection[] selections, Dictionary localIdentifiers, Dictionary componentIdentifiers, Dictionary references) @@ -338,29 +460,43 @@ .. model.Elements group => group.OrderBy(element => element.Id, StringComparer.Ordinal).ToArray(), StringComparer.Ordinal); + Dictionary selectionsByContainer = selections + .ToDictionary( + selection => selection.SelectedContainerId, + StringComparer.Ordinal); + foreach (ArchitectureElement root in roots) { EmitElementWithC3( builder, root, - c3, + selectionsByContainer, localIdentifiers, componentIdentifiers, children, 1); } - if ((roots.Length > 0 && model.Relations.Length > 0) || c3.Relations.Length > 0) + ArchitectureComponentRelation[] componentRelations = + [ + .. selections + .SelectMany(selection => selection.Relations) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + + if ((roots.Length > 0 && model.Relations.Length > 0) || + componentRelations.Length > 0) { builder.Append('\n'); } - foreach (ArchitectureRelation relation in model.Relations.OrderBy(item => item.Id, StringComparer.Ordinal)) + foreach (ArchitectureRelation relation in model.Relations + .OrderBy(item => item.Id, StringComparer.Ordinal)) { EmitRelation(builder, relation, references, 1); } - foreach (ArchitectureComponentRelation relation in c3.Relations.OrderBy(item => item.Id, StringComparer.Ordinal)) + foreach (ArchitectureComponentRelation relation in componentRelations) { AppendLine( builder, @@ -389,7 +525,7 @@ .. model.Elements private static void EmitElementWithC3( StringBuilder builder, ArchitectureElement element, - ArchitectureC3Model c3, + Dictionary selectionsByContainer, Dictionary localIdentifiers, Dictionary componentIdentifiers, Dictionary children, @@ -421,10 +557,13 @@ private static void EmitElementWithC3( element.ReviewReason, indent + 1); - if (element.Id == c3.SelectedContainerId) + if (selectionsByContainer.TryGetValue( + element.Id, + out ArchitectureC3Selection? selection)) { builder.Append('\n'); - foreach (ArchitectureComponent component in c3.Components.OrderBy(item => item.Id, StringComparer.Ordinal)) + foreach (ArchitectureComponent component in selection.Components + .OrderBy(item => item.Id, StringComparer.Ordinal)) { AppendLine( builder, @@ -435,7 +574,10 @@ private static void EmitElementWithC3( AppendLine(builder, indent + 2, "#" + RequiresReviewTag); } - AppendLine(builder, indent + 2, "description " + Quote(component.Responsibility)); + AppendLine( + builder, + indent + 2, + "description " + Quote(component.Responsibility)); EmitMetadata( builder, component.Id, @@ -455,7 +597,7 @@ private static void EmitElementWithC3( EmitElementWithC3( builder, child, - c3, + selectionsByContainer, localIdentifiers, componentIdentifiers, children, @@ -466,38 +608,56 @@ private static void EmitElementWithC3( AppendLine(builder, indent, "}"); } - private static string EmitC3View( + private static string EmitC3Views( ArchitectureModel model, - ArchitectureC3Model c3, - Dictionary references) + ArchitectureC3Selection[] selections, + Dictionary references, + Dictionary viewIdentifiers) { StringBuilder builder = new(); - ArchitectureElement selected = model.Elements.First(item => item.Id == c3.SelectedContainerId); + Dictionary elements = model.Elements + .ToDictionary(element => element.Id, StringComparer.Ordinal); + AppendLine(builder, 0, "views {"); - AppendLine(builder, 1, "view c3 {"); - AppendLine(builder, 2, "title " + Quote("C3 - " + selected.Name)); - AppendLine(builder, 2, "include " + references[selected.Id]); - foreach (ArchitectureComponent component in c3.Components.OrderBy(item => item.Id, StringComparer.Ordinal)) + + foreach (ArchitectureC3Selection selection in selections) { - AppendLine(builder, 2, "include " + references[component.Id]); + ArchitectureElement selected = elements[selection.SelectedContainerId]; + AppendLine( + builder, + 1, + "view " + viewIdentifiers[selection.SelectedContainerId] + " {"); + AppendLine(builder, 2, "title " + Quote("C3 - " + selected.Name)); + AppendLine(builder, 2, "include " + references[selected.Id]); + foreach (ArchitectureComponent component in selection.Components + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + AppendLine(builder, 2, "include " + references[component.Id]); + } + + AppendLine(builder, 1, "}"); } - AppendLine(builder, 1, "}"); AppendLine(builder, 0, "}"); return builder.ToString(); } - private static string EmitViews(ArchitectureModel model, Dictionary references) + private static string EmitViews( + ArchitectureModel model, + Dictionary references) { StringBuilder builder = new(); string viewName = model.Level == ArchitectureLevel.C1 ? "c1" : "c2"; - string title = model.Level == ArchitectureLevel.C1 ? "C1 - System Context" : "C2 - Containers"; + string title = model.Level == ArchitectureLevel.C1 + ? "C1 - System Context" + : "C2 - Containers"; AppendLine(builder, 0, "views {"); AppendLine(builder, 1, "view " + viewName + " {"); AppendLine(builder, 2, "title " + Quote(title)); - foreach (ArchitectureElement element in model.Elements.OrderBy(item => item.Id, StringComparer.Ordinal)) + foreach (ArchitectureElement element in model.Elements + .OrderBy(item => item.Id, StringComparer.Ordinal)) { AppendLine(builder, 2, "include " + references[element.Id]); } @@ -512,7 +672,9 @@ private static string Quote(string value) StringBuilder builder = new(value.Length + 2); builder.Append('"'); - string normalized = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + string normalized = value + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); foreach (char character in normalized) { switch (character) @@ -548,7 +710,10 @@ private static string Quote(string value) return builder.ToString(); } - private static void AppendLine(StringBuilder builder, int indent, string text) + private static void AppendLine( + StringBuilder builder, + int indent, + string text) { builder.Append(' ', indent * 2); builder.Append(text); From a50b2a7a5cfccd0b6e0b650e90684eb2b13bca1f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:15:09 -0300 Subject: [PATCH 23/74] feat: accept repeated c3 container options in CLI (#67) --- src/Repo2C4.Cli/CliApplication.cs | 80 +++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/Repo2C4.Cli/CliApplication.cs b/src/Repo2C4.Cli/CliApplication.cs index 901d4eb8..e125592c 100644 --- a/src/Repo2C4.Cli/CliApplication.cs +++ b/src/Repo2C4.Cli/CliApplication.cs @@ -504,15 +504,21 @@ private static async Task RunGenerateAsync( TextWriter standardError, CancellationToken cancellationToken) { - if (!TryParseOptions( - args, - ["--model", "--output", "--c3-container"], - ["--apply"], - out Dictionary values, - out HashSet flags, - out string? parseError)) - { - standardError.WriteLine(parseError); + if (!TryExtractRepeatedValueOption( + args, + "--c3-container", + out string[] remainingArgs, + out string[] selectedContainers, + out string? repeatedOptionError) || + !TryParseOptions( + remainingArgs, + ["--model", "--output"], + ["--apply"], + out Dictionary values, + out HashSet flags, + out string? parseError)) + { + standardError.WriteLine(repeatedOptionError ?? parseError); return CliExitCodes.UsageError; } @@ -541,16 +547,10 @@ private static async Task RunGenerateAsync( string json = await File.ReadAllTextAsync(fullModelPath, cancellationToken).ConfigureAwait(false); ArchitectureModel model = ContractJson.DeserializeModel(json); - IReadOnlyList files; - if (values.TryGetValue("--c3-container", out string? selectedContainer)) - { - ArchitectureC3Model c3 = ArchitectureC3Builder.Build(model, selectedContainer); - files = LikeC4Emitter.EmitWithC3(model, c3); - } - else - { - files = LikeC4Emitter.Emit(model); - } + ArchitectureC3Workspace c3Workspace = + ArchitectureC3Builder.BuildMany(model, selectedContainers); + IReadOnlyList files = + LikeC4Emitter.EmitWithC3(c3Workspace); EvidenceReportResult report = EvidenceReportGenerator.Generate(model); List managedFiles = @@ -681,6 +681,44 @@ or NotSupportedException } } + private static bool TryExtractRepeatedValueOption( + string[] args, + string option, + out string[] remainingArgs, + out string[] values, + out string? error) + { + List remaining = []; + List repeatedValues = []; + + for (int index = 0; index < args.Length; index++) + { + string token = args[index]; + if (!string.Equals(token, option, StringComparison.Ordinal)) + { + remaining.Add(token); + continue; + } + + if (index + 1 >= args.Length || + args[index + 1].StartsWith("--", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(args[index + 1])) + { + remainingArgs = []; + values = []; + error = "Missing value for option: " + option + "."; + return false; + } + + repeatedValues.Add(args[++index]); + } + + remainingArgs = [.. remaining]; + values = [.. repeatedValues]; + error = null; + return true; + } + private static bool TryParseOptions( string[] args, string[] valueOptions, @@ -776,8 +814,8 @@ private static int WriteCommandHelp(string command, TextWriter output, TextWrite output.WriteLine("Collects bounded evidence only. Remote HTTPS acquisition uses an isolated temporary workspace and writes separate acquisition provenance."); return CliExitCodes.Success; case "generate": - output.WriteLine("Usage: repo2c4 generate --model architecture.json --output DIR [--c3-container ID] [--apply]"); - output.WriteLine("Previews deterministic C1/C2 outputs and evidence-report.md; --apply writes only managed, unchanged outputs. --c3-container ID adds selected C3."); + output.WriteLine("Usage: repo2c4 generate --model architecture.json --output DIR [--c3-container ID ...] [--apply]"); + output.WriteLine("Previews deterministic C1/C2 outputs and evidence-report.md; --apply writes only managed, unchanged outputs. Repeat --c3-container ID to add multiple selected C3 views to one workspace."); return CliExitCodes.Success; case "validate": output.WriteLine("Usage: repo2c4 validate --output DIR"); From 38a0e42002f02d64fd749839b37cf58421c3457a Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:17:09 -0300 Subject: [PATCH 24/74] test: cover multi-container C3 generation (#67) --- examples/models/multi-c3.c2.v1.json | 32 ++++ tests/Repo2C4.Cli.Tests/CliHostTests.cs | 176 ++++++++++++++++++ .../Repo2C4.Core.Tests/ArchitectureC3Tests.cs | 117 ++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 examples/models/multi-c3.c2.v1.json diff --git a/examples/models/multi-c3.c2.v1.json b/examples/models/multi-c3.c2.v1.json new file mode 100644 index 00000000..f1975b44 --- /dev/null +++ b/examples/models/multi-c3.c2.v1.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": "1.0", + "level": "C2", + "snapshot": { + "schemaVersion": "1.0", + "repositoryId": "multi_c3_fixture", + "files": [ + {"relativePath": "Suite.slnx", "sizeBytes": 100}, + {"relativePath": "src/Alpha/Alpha.csproj", "sizeBytes": 100}, + {"relativePath": "src/Beta/Beta.csproj", "sizeBytes": 100}, + {"relativePath": "src/Gamma/Gamma.csproj", "sizeBytes": 100}, + {"relativePath": "src/Delta/Delta.csproj", "sizeBytes": 100} + ], + "evidence": [ + {"id":"ev_solution","category":"dotnet.solution.project","relativePath":"Suite.slnx","line":1,"sourceType":"manifest","description":"Solution declares selected application projects."}, + {"id":"ev_alpha_http","category":"dotnet.runtime.http.candidate","relativePath":"src/Alpha/Alpha.csproj","line":1,"sourceType":"projectFile","description":"Alpha is an HTTP host candidate."}, + {"id":"ev_beta_http","category":"dotnet.runtime.http.candidate","relativePath":"src/Beta/Beta.csproj","line":1,"sourceType":"projectFile","description":"Beta is an HTTP host candidate."}, + {"id":"ev_gamma_http","category":"dotnet.runtime.http.candidate","relativePath":"src/Gamma/Gamma.csproj","line":1,"sourceType":"projectFile","description":"Gamma is an HTTP host candidate."}, + {"id":"ev_delta_http","category":"dotnet.runtime.http.candidate","relativePath":"src/Delta/Delta.csproj","line":1,"sourceType":"projectFile","description":"Delta is an HTTP host candidate."} + ], + "diagnostics": [] + }, + "elements": [ + {"id":"el_user","kind":"actor","name":"User","evidenceIds":[],"status":"requiresReview","reviewReason":"Actor is included only to exercise non-container selection validation."}, + {"id":"el_suite","kind":"softwareSystem","name":"Suite","evidenceIds":["ev_solution"],"status":"confirmed"}, + {"id":"el_alpha","kind":"container","name":"Alpha API","parentId":"el_suite","evidenceIds":["ev_alpha_http"],"status":"requiresReview","reviewReason":"Deployment boundary requires review."}, + {"id":"el_beta","kind":"container","name":"Beta API","parentId":"el_suite","evidenceIds":["ev_beta_http"],"status":"requiresReview","reviewReason":"Deployment boundary requires review."}, + {"id":"el_gamma","kind":"container","name":"Gamma API","parentId":"el_suite","evidenceIds":["ev_gamma_http"],"status":"requiresReview","reviewReason":"Deployment boundary requires review."}, + {"id":"el_delta","kind":"container","name":"Delta API","parentId":"el_suite","evidenceIds":["ev_delta_http"],"status":"requiresReview","reviewReason":"Deployment boundary requires review."} + ], + "relations": [] +} diff --git a/tests/Repo2C4.Cli.Tests/CliHostTests.cs b/tests/Repo2C4.Cli.Tests/CliHostTests.cs index e5b93fe4..eb2e9339 100644 --- a/tests/Repo2C4.Cli.Tests/CliHostTests.cs +++ b/tests/Repo2C4.Cli.Tests/CliHostTests.cs @@ -211,6 +211,182 @@ public void GenerateSelectedC3WritesOnlyRequestedComponentView() Assert.DoesNotContain("el_worker", c3View, StringComparison.Ordinal); } + [Fact] + public void GenerateRepeatedC3ContainerOptionsCreateOneCanonicalMultiViewWorkspace() + { + string model = Path.Combine(AppContext.BaseDirectory, "Models", "multi-c3.c2.v1.json"); + using TempDirectory temp = new(); + string outputDirectory = Path.Combine(temp.Path, "likec4"); + + int exitCode = Run([ + "generate", + "--model", model, + "--output", outputDirectory, + "--c3-container", "el_delta", + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + "--c3-container", "el_alpha", + "--apply", + ]); + + Assert.Equal(CliExitCodes.Success, exitCode); + string generatedModel = File.ReadAllText(Path.Combine(outputDirectory, "model.c4")); + Assert.Equal(3, generatedModel.Split(" = component ", StringSplitOptions.None).Length - 1); + + string views = File.ReadAllText(Path.Combine(outputDirectory, "c3.views.c4")); + Assert.Contains("view c3_el_alpha {", views, StringComparison.Ordinal); + Assert.Contains("view c3_el_beta {", views, StringComparison.Ordinal); + Assert.Contains("view c3_el_delta {", views, StringComparison.Ordinal); + Assert.DoesNotContain("c3_el_gamma", views, StringComparison.Ordinal); + } + + [Fact] + public void GenerateMultiC3IsDeterministicAcrossArgumentOrderAndDuplicates() + { + string model = Path.Combine(AppContext.BaseDirectory, "Models", "multi-c3.c2.v1.json"); + using TempDirectory first = new(); + using TempDirectory second = new(); + string firstOutput = Path.Combine(first.Path, "likec4"); + string secondOutput = Path.Combine(second.Path, "likec4"); + + Assert.Equal( + CliExitCodes.Success, + Run([ + "generate", "--model", model, "--output", firstOutput, + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + "--c3-container", "el_gamma", + "--apply", + ])); + Assert.Equal( + CliExitCodes.Success, + Run([ + "generate", "--model", model, "--output", secondOutput, + "--c3-container", "el_gamma", + "--c3-container", "el_alpha", + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + "--apply", + ])); + + Assert.Equal( + File.ReadAllText(Path.Combine(firstOutput, "model.c4")), + File.ReadAllText(Path.Combine(secondOutput, "model.c4"))); + Assert.Equal( + File.ReadAllText(Path.Combine(firstOutput, "c3.views.c4")), + File.ReadAllText(Path.Combine(secondOutput, "c3.views.c4"))); + } + + [Theory] + [InlineData("el_missing")] + [InlineData("el_suite")] + [InlineData("el_user")] + public void GenerateInvalidC3SelectionFailsBeforeWriting(string selection) + { + string model = Path.Combine(AppContext.BaseDirectory, "Models", "multi-c3.c2.v1.json"); + using TempDirectory temp = new(); + string outputDirectory = Path.Combine(temp.Path, "likec4"); + + int exitCode = Run([ + "generate", + "--model", model, + "--output", outputDirectory, + "--c3-container", selection, + "--apply", + ]); + + Assert.Equal(CliExitCodes.InvalidData, exitCode); + Assert.False(Directory.Exists(outputDirectory)); + } + + [Fact] + public void GenerateRejectsC3SelectionForC1BeforeWriting() + { + string model = Path.Combine(AppContext.BaseDirectory, "EndToEnd", "architecture.c1.v1.json"); + using TempDirectory temp = new(); + string outputDirectory = Path.Combine(temp.Path, "likec4"); + + int exitCode = Run([ + "generate", + "--model", model, + "--output", outputDirectory, + "--c3-container", "el_library_repo", + "--apply", + ]); + + Assert.Equal(CliExitCodes.InvalidData, exitCode); + Assert.False(Directory.Exists(outputDirectory)); + } + + [Fact] + public void ManagedRegenerationUpdatesSelectionButProtectsManualMultiC3Edits() + { + string model = Path.Combine(AppContext.BaseDirectory, "Models", "multi-c3.c2.v1.json"); + using TempDirectory temp = new(); + string outputDirectory = Path.Combine(temp.Path, "likec4"); + + Assert.Equal( + CliExitCodes.Success, + Run([ + "generate", "--model", model, "--output", outputDirectory, + "--c3-container", "el_alpha", + "--apply", + ])); + + Assert.Equal( + CliExitCodes.Success, + Run([ + "generate", "--model", model, "--output", outputDirectory, + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + "--apply", + ])); + + string c3Views = Path.Combine(outputDirectory, "c3.views.c4"); + Assert.Contains("c3_el_beta", File.ReadAllText(c3Views), StringComparison.Ordinal); + + File.AppendAllText(c3Views, "// manual multi-c3 edit"); + int conflict = Run([ + "generate", "--model", model, "--output", outputDirectory, + "--c3-container", "el_alpha", + "--apply", + ]); + + Assert.Equal(CliExitCodes.IoError, conflict); + Assert.EndsWith( + "// manual multi-c3 edit", + File.ReadAllText(c3Views), + StringComparison.Ordinal); + } + + [Fact] + public void MultiC3GenerateAndValidateUsesOfficialLikeC4WhenIntegrationIsEnabled() + { + if (!string.Equals( + Environment.GetEnvironmentVariable("REPO2C4_LIKEC4_INTEGRATION"), + "1", + StringComparison.Ordinal)) + { + return; + } + + string model = Path.Combine(AppContext.BaseDirectory, "Models", "multi-c3.c2.v1.json"); + using TempDirectory temp = new(); + string outputDirectory = Path.Combine(temp.Path, "likec4"); + + Assert.Equal( + CliExitCodes.Success, + Run([ + "generate", "--model", model, "--output", outputDirectory, + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + "--apply", + ])); + Assert.Equal( + CliExitCodes.Success, + Run(["validate", "--output", outputDirectory])); + } + [Fact] public void GenerateInvalidModelUsesDistinctInvalidDataExitCode() { diff --git a/tests/Repo2C4.Core.Tests/ArchitectureC3Tests.cs b/tests/Repo2C4.Core.Tests/ArchitectureC3Tests.cs index 67daa3b2..fb264324 100644 --- a/tests/Repo2C4.Core.Tests/ArchitectureC3Tests.cs +++ b/tests/Repo2C4.Core.Tests/ArchitectureC3Tests.cs @@ -196,6 +196,123 @@ public void MalformedBaseAndRelationIdsReturnValidationErrors() Assert.Contains(errors, error => error.Code == "relation.destinationMissing"); } + [Fact] + public void ZeroSelectionsKeepC2OnlyOutput() + { + ArchitectureModel c2 = LoadModel("multi-c3.c2.v1.json"); + + ArchitectureC3Workspace workspace = ArchitectureC3Builder.BuildMany(c2, []); + IReadOnlyList actual = LikeC4Emitter.EmitWithC3(workspace); + IReadOnlyList expected = LikeC4Emitter.Emit(c2); + + Assert.Empty(workspace.Selections); + Assert.Equal( + expected.Select(file => (file.FileName, file.Content)), + actual.Select(file => (file.FileName, file.Content))); + Assert.DoesNotContain(actual, file => file.FileName == LikeC4Emitter.C3ViewsFileName); + } + + [Fact] + public void SingleSelectionWorkspaceIsBackwardCompatibleWithLegacyEmission() + { + ArchitectureModel c2 = LoadModel("multi-c3.c2.v1.json"); + ArchitectureC3Model single = ArchitectureC3Builder.Build(c2, "el_alpha"); + ArchitectureC3Workspace workspace = ArchitectureC3Builder.BuildMany(c2, ["el_alpha"]); + + IReadOnlyList legacy = LikeC4Emitter.EmitWithC3(c2, single); + IReadOnlyList multi = LikeC4Emitter.EmitWithC3(workspace); + + Assert.Equal( + legacy.Select(file => (file.FileName, file.Content)), + multi.Select(file => (file.FileName, file.Content))); + Assert.Contains( + "view c3 {", + multi.Single(file => file.FileName == LikeC4Emitter.C3ViewsFileName).Content, + StringComparison.Ordinal); + } + + [Fact] + public void MultipleSelectionsAreCanonicalDeduplicatedAndShareOneWorkspace() + { + ArchitectureModel c2 = LoadModel("multi-c3.c2.v1.json"); + + ArchitectureC3Workspace workspace = ArchitectureC3Builder.BuildMany( + c2, + ["el_delta", "el_alpha", "el_beta", "el_alpha", "el_gamma"]); + IReadOnlyList files = LikeC4Emitter.EmitWithC3(workspace); + + Assert.Equal( + ["el_alpha", "el_beta", "el_delta", "el_gamma"], + workspace.Selections.Select(selection => selection.SelectedContainerId)); + + string model = files.Single(file => file.FileName == LikeC4Emitter.ModelFileName).Content; + Assert.Equal(4, model.Split(" = component ", StringSplitOptions.None).Length - 1); + + string views = files.Single(file => file.FileName == LikeC4Emitter.C3ViewsFileName).Content; + Assert.Contains("view c3_el_alpha {", views, StringComparison.Ordinal); + Assert.Contains("view c3_el_beta {", views, StringComparison.Ordinal); + Assert.Contains("view c3_el_delta {", views, StringComparison.Ordinal); + Assert.Contains("view c3_el_gamma {", views, StringComparison.Ordinal); + Assert.Contains("C3 - Alpha API", views, StringComparison.Ordinal); + Assert.Contains("C3 - Beta API", views, StringComparison.Ordinal); + Assert.Contains("C3 - Gamma API", views, StringComparison.Ordinal); + Assert.Contains("C3 - Delta API", views, StringComparison.Ordinal); + Assert.Equal(1, views.Split("views {", StringSplitOptions.None).Length - 1); + } + + [Fact] + public void SelectionArgumentOrderDoesNotChangeGeneratedWorkspace() + { + ArchitectureModel c2 = LoadModel("multi-c3.c2.v1.json"); + + ArchitectureC3Workspace first = ArchitectureC3Builder.BuildMany( + c2, + ["el_gamma", "el_alpha", "el_beta"]); + ArchitectureC3Workspace second = ArchitectureC3Builder.BuildMany( + c2, + ["el_beta", "el_gamma", "el_alpha", "el_alpha"]); + + IReadOnlyList firstFiles = LikeC4Emitter.EmitWithC3(first); + IReadOnlyList secondFiles = LikeC4Emitter.EmitWithC3(second); + + Assert.Equal( + firstFiles.Select(file => (file.FileName, file.Content)), + secondFiles.Select(file => (file.FileName, file.Content))); + } + + [Fact] + public void InvalidOrNonContainerSelectionsAndC1BaseAreRejected() + { + ArchitectureModel c2 = LoadModel("multi-c3.c2.v1.json"); + + ContractValidationException missing = Assert.Throws( + () => ArchitectureC3Builder.BuildMany(c2, ["el_missing"])); + Assert.Contains(missing.Errors, error => error.Code == "c3.containerMissing"); + + ContractValidationException system = Assert.Throws( + () => ArchitectureC3Builder.BuildMany(c2, ["el_suite"])); + Assert.Contains(system.Errors, error => error.Code == "c3.containerKind"); + + ContractValidationException actor = Assert.Throws( + () => ArchitectureC3Builder.BuildMany(c2, ["el_user"])); + Assert.Contains(actor.Errors, error => error.Code == "c3.containerKind"); + + ArchitectureModel c1 = c2 with + { + Level = ArchitectureLevel.C1, + Elements = + [ + .. c2.Elements.Where(element => + element.Kind is ArchitectureElementKind.Actor or ArchitectureElementKind.SoftwareSystem), + ], + Relations = [], + }; + + ContractValidationException invalidLevel = Assert.Throws( + () => ArchitectureC3Builder.BuildMany(c1, ["el_suite"])); + Assert.Contains(invalidLevel.Errors, error => error.Code == "c3.baseLevel"); + } + private static ArchitectureModel LoadModel(string fileName) { string path = Path.Combine(AppContext.BaseDirectory, "MappingFixtures", fileName); From 2d630b9622a10f8609e1b7f4fffdfed97a6aeeab Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:17:56 -0300 Subject: [PATCH 25/74] style: format multi-container C3 validation (#67) --- .../Contracts/ArchitectureC3Validator.cs | 127 +++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index 6aad0d8f..1419dbc1 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -252,7 +252,132 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo ImmutableArray baseErrors = ContractValidator.ValidateModel(workspace.BaseModel); errors.AddRange(baseErrors.Select(error => - error with { Path = "$.baseModel" + error.Path.TrimStart('$') })); + error with + { + Path = "$.baseModel" + error.Path.TrimStart(' + if (!baseErrors.IsEmpty) + { + return [.. errors]; + } + + if (workspace.Selections.IsDefault) + { + errors.Add(new ContractError( + "collection.missing", + "$.selections", + "C3 selections must be an initialized collection.")); + return [.. errors]; + } + + if (!workspace.Selections.IsEmpty && + workspace.BaseModel.Level != ArchitectureLevel.C2) + { + errors.Add(new ContractError( + "c3.baseLevel", + "$.baseModel.level", + "C3 requires a C2 base model.")); + } + + HashSet selectedContainerIds = new(StringComparer.Ordinal); + HashSet componentIds = new(StringComparer.Ordinal); + HashSet relationIds = new(StringComparer.Ordinal); + + for (int i = 0; i < workspace.Selections.Length; i++) + { + ArchitectureC3Selection? selection = workspace.Selections[i]; + string path = "$.selections[" + i + "]"; + + if (selection is null) + { + errors.Add(new ContractError( + "c3.selectionMissing", + path, + "C3 selection must not be null.")); + continue; + } + + if (string.IsNullOrWhiteSpace(selection.SelectedContainerId)) + { + errors.Add(new ContractError( + "c3.containerMissing", + path + ".selectedContainerId", + "Selected C3 container ID is required.")); + } + else if (!selectedContainerIds.Add(selection.SelectedContainerId)) + { + errors.Add(new ContractError( + "c3.selectionDuplicate", + path + ".selectedContainerId", + "A C3 container may appear only once in the canonical workspace.")); + } + + ArchitectureC3Model model = new( + workspace.SchemaVersion, + workspace.BaseModel, + selection.SelectedContainerId, + selection.Components, + selection.Relations); + + foreach (ContractError error in ArchitectureC3Validator.Validate(model)) + { + if (error.Path.StartsWith("$.baseModel", StringComparison.Ordinal) || + error.Code == "schema.unsupported" || + error.Code == "c3.baseLevel") + { + continue; + } + + errors.Add(error with + { + Path = path + error.Path.TrimStart('$'), + }); + } + + if (selection.Components.IsDefault || selection.Relations.IsDefault) + { + continue; + } + + for (int componentIndex = 0; componentIndex < selection.Components.Length; componentIndex++) + { + ArchitectureComponent? component = selection.Components[componentIndex]; + if (component is null || string.IsNullOrWhiteSpace(component.Id)) + { + continue; + } + + if (!componentIds.Add(component.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".components[" + componentIndex + "].id", + "C3 component IDs must be unique across all selected containers.")); + } + } + + for (int relationIndex = 0; relationIndex < selection.Relations.Length; relationIndex++) + { + ArchitectureComponentRelation? relation = selection.Relations[relationIndex]; + if (relation is null || string.IsNullOrWhiteSpace(relation.Id)) + { + continue; + } + + if (!relationIds.Add(relation.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".relations[" + relationIndex + "].id", + "C3 relation IDs must be unique across all selected containers.")); + } + } + } + + return [.. errors]; + } +} +), + })); if (!baseErrors.IsEmpty) { return [.. errors]; From 4f0008a22fc9f3dfa4bcd818f61402acf4a82381 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:20:33 -0300 Subject: [PATCH 26/74] fix: repair multi-container C3 validator structure (#67) --- .../Contracts/ArchitectureC3Validator.cs | 137 +----------------- 1 file changed, 3 insertions(+), 134 deletions(-) diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index 1419dbc1..4f63fb2d 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -228,7 +228,6 @@ private static void ValidateProvenance( } } - /// Validates multiple C3 selections against one shared base model and global C3 identity space. public static class ArchitectureC3WorkspaceValidator { @@ -251,133 +250,7 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo } ImmutableArray baseErrors = ContractValidator.ValidateModel(workspace.BaseModel); - errors.AddRange(baseErrors.Select(error => - error with - { - Path = "$.baseModel" + error.Path.TrimStart(' - if (!baseErrors.IsEmpty) - { - return [.. errors]; - } - - if (workspace.Selections.IsDefault) - { - errors.Add(new ContractError( - "collection.missing", - "$.selections", - "C3 selections must be an initialized collection.")); - return [.. errors]; - } - - if (!workspace.Selections.IsEmpty && - workspace.BaseModel.Level != ArchitectureLevel.C2) - { - errors.Add(new ContractError( - "c3.baseLevel", - "$.baseModel.level", - "C3 requires a C2 base model.")); - } - - HashSet selectedContainerIds = new(StringComparer.Ordinal); - HashSet componentIds = new(StringComparer.Ordinal); - HashSet relationIds = new(StringComparer.Ordinal); - - for (int i = 0; i < workspace.Selections.Length; i++) - { - ArchitectureC3Selection? selection = workspace.Selections[i]; - string path = "$.selections[" + i + "]"; - - if (selection is null) - { - errors.Add(new ContractError( - "c3.selectionMissing", - path, - "C3 selection must not be null.")); - continue; - } - - if (string.IsNullOrWhiteSpace(selection.SelectedContainerId)) - { - errors.Add(new ContractError( - "c3.containerMissing", - path + ".selectedContainerId", - "Selected C3 container ID is required.")); - } - else if (!selectedContainerIds.Add(selection.SelectedContainerId)) - { - errors.Add(new ContractError( - "c3.selectionDuplicate", - path + ".selectedContainerId", - "A C3 container may appear only once in the canonical workspace.")); - } - - ArchitectureC3Model model = new( - workspace.SchemaVersion, - workspace.BaseModel, - selection.SelectedContainerId, - selection.Components, - selection.Relations); - - foreach (ContractError error in ArchitectureC3Validator.Validate(model)) - { - if (error.Path.StartsWith("$.baseModel", StringComparison.Ordinal) || - error.Code == "schema.unsupported" || - error.Code == "c3.baseLevel") - { - continue; - } - - errors.Add(error with - { - Path = path + error.Path.TrimStart('$'), - }); - } - - if (selection.Components.IsDefault || selection.Relations.IsDefault) - { - continue; - } - - for (int componentIndex = 0; componentIndex < selection.Components.Length; componentIndex++) - { - ArchitectureComponent? component = selection.Components[componentIndex]; - if (component is null || string.IsNullOrWhiteSpace(component.Id)) - { - continue; - } - - if (!componentIds.Add(component.Id)) - { - errors.Add(new ContractError( - "id.duplicate", - path + ".components[" + componentIndex + "].id", - "C3 component IDs must be unique across all selected containers.")); - } - } - - for (int relationIndex = 0; relationIndex < selection.Relations.Length; relationIndex++) - { - ArchitectureComponentRelation? relation = selection.Relations[relationIndex]; - if (relation is null || string.IsNullOrWhiteSpace(relation.Id)) - { - continue; - } - - if (!relationIds.Add(relation.Id)) - { - errors.Add(new ContractError( - "id.duplicate", - path + ".relations[" + relationIndex + "].id", - "C3 relation IDs must be unique across all selected containers.")); - } - } - } - - return [.. errors]; - } -} -), - })); + errors.AddRange(baseErrors.Select(error => error with { Path = "$.baseModel" + error.Path.TrimStart('$') })); if (!baseErrors.IsEmpty) { return [.. errors]; @@ -392,8 +265,7 @@ error with return [.. errors]; } - if (!workspace.Selections.IsEmpty && - workspace.BaseModel.Level != ArchitectureLevel.C2) + if (!workspace.Selections.IsEmpty && workspace.BaseModel.Level != ArchitectureLevel.C2) { errors.Add(new ContractError( "c3.baseLevel", @@ -450,10 +322,7 @@ error with continue; } - errors.Add(error with - { - Path = path + error.Path.TrimStart('$'), - }); + errors.Add(error with { Path = path + error.Path.TrimStart('$') }); } if (selection.Components.IsDefault || selection.Relations.IsDefault) From e7621eb54cbece37ebcf48540293c8e6d0a5173e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:22:27 -0300 Subject: [PATCH 27/74] style: align C3 workspace error projection (#67) --- .../Contracts/ArchitectureC3Validator.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index 4f63fb2d..46743e1c 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -322,7 +322,56 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo continue; } - errors.Add(error with { Path = path + error.Path.TrimStart('$') }); + errors.Add(error with + { + Path = path + error.Path.TrimStart(' + } + + if (selection.Components.IsDefault || selection.Relations.IsDefault) + { + continue; + } + + for (int componentIndex = 0; componentIndex < selection.Components.Length; componentIndex++) + { + ArchitectureComponent? component = selection.Components[componentIndex]; + if (component is null || string.IsNullOrWhiteSpace(component.Id)) + { + continue; + } + + if (!componentIds.Add(component.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".components[" + componentIndex + "].id", + "C3 component IDs must be unique across all selected containers.")); + } + } + + for (int relationIndex = 0; relationIndex < selection.Relations.Length; relationIndex++) + { + ArchitectureComponentRelation? relation = selection.Relations[relationIndex]; + if (relation is null || string.IsNullOrWhiteSpace(relation.Id)) + { + continue; + } + + if (!relationIds.Add(relation.Id)) + { + errors.Add(new ContractError( + "id.duplicate", + path + ".relations[" + relationIndex + "].id", + "C3 relation IDs must be unique across all selected containers.")); + } + } + } + + return [.. errors]; + } +} +), + }); } if (selection.Components.IsDefault || selection.Relations.IsDefault) From 5994ac11d96cdb71ea2a8319482ecdf8b6703e97 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:26:15 -0300 Subject: [PATCH 28/74] fix: stabilize multi-container C3 validator source (#67) --- .../Contracts/ArchitectureC3Validator.cs | 56 +++---------------- 1 file changed, 7 insertions(+), 49 deletions(-) diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index 46743e1c..b11b2e9d 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -250,7 +250,10 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo } ImmutableArray baseErrors = ContractValidator.ValidateModel(workspace.BaseModel); - errors.AddRange(baseErrors.Select(error => error with { Path = "$.baseModel" + error.Path.TrimStart('$') })); + errors.AddRange(baseErrors.Select(error => error with + { + Path = "$.baseModel" + error.Path.TrimStart((char)36), + })); if (!baseErrors.IsEmpty) { return [.. errors]; @@ -265,7 +268,8 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo return [.. errors]; } - if (!workspace.Selections.IsEmpty && workspace.BaseModel.Level != ArchitectureLevel.C2) + if (!workspace.Selections.IsEmpty && + workspace.BaseModel.Level != ArchitectureLevel.C2) { errors.Add(new ContractError( "c3.baseLevel", @@ -324,53 +328,7 @@ public static ImmutableArray Validate(ArchitectureC3Workspace? wo errors.Add(error with { - Path = path + error.Path.TrimStart(' - } - - if (selection.Components.IsDefault || selection.Relations.IsDefault) - { - continue; - } - - for (int componentIndex = 0; componentIndex < selection.Components.Length; componentIndex++) - { - ArchitectureComponent? component = selection.Components[componentIndex]; - if (component is null || string.IsNullOrWhiteSpace(component.Id)) - { - continue; - } - - if (!componentIds.Add(component.Id)) - { - errors.Add(new ContractError( - "id.duplicate", - path + ".components[" + componentIndex + "].id", - "C3 component IDs must be unique across all selected containers.")); - } - } - - for (int relationIndex = 0; relationIndex < selection.Relations.Length; relationIndex++) - { - ArchitectureComponentRelation? relation = selection.Relations[relationIndex]; - if (relation is null || string.IsNullOrWhiteSpace(relation.Id)) - { - continue; - } - - if (!relationIds.Add(relation.Id)) - { - errors.Add(new ContractError( - "id.duplicate", - path + ".relations[" + relationIndex + "].id", - "C3 relation IDs must be unique across all selected containers.")); - } - } - } - - return [.. errors]; - } -} -), + Path = path + error.Path.TrimStart((char)36), }); } From 5c149a7b38f9611b63543a671c23a382eeed164b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:28:00 -0300 Subject: [PATCH 29/74] fix: parse repeated C3 selections before CLI options (#67) --- src/Repo2C4.Cli/CliApplication.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Repo2C4.Cli/CliApplication.cs b/src/Repo2C4.Cli/CliApplication.cs index e125592c..9c140bd2 100644 --- a/src/Repo2C4.Cli/CliApplication.cs +++ b/src/Repo2C4.Cli/CliApplication.cs @@ -509,8 +509,13 @@ private static async Task RunGenerateAsync( "--c3-container", out string[] remainingArgs, out string[] selectedContainers, - out string? repeatedOptionError) || - !TryParseOptions( + out string? repeatedOptionError)) + { + standardError.WriteLine(repeatedOptionError); + return CliExitCodes.UsageError; + } + + if (!TryParseOptions( remainingArgs, ["--model", "--output"], ["--apply"], @@ -518,7 +523,7 @@ private static async Task RunGenerateAsync( out HashSet flags, out string? parseError)) { - standardError.WriteLine(repeatedOptionError ?? parseError); + standardError.WriteLine(parseError); return CliExitCodes.UsageError; } From 03938f651816c42a62e1265584801b4f0446a346 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:31:18 -0300 Subject: [PATCH 30/74] fix: preserve single-container C3 validation behavior (#67) --- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index ddfb0996..41d9277d 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -17,8 +17,30 @@ public static ArchitectureC3Model Build( ArchitectureModel baseModel, string selectedContainerId) { + ArgumentNullException.ThrowIfNull(baseModel); ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); + ImmutableArray baseErrors = + ContractValidator.ValidateModel(baseModel); + if (!baseErrors.IsEmpty) + { + throw new ContractValidationException(baseErrors); + } + + ArchitectureElement? selected = baseModel.Elements.FirstOrDefault(item => + item.Id == selectedContainerId && + item.Kind == ArchitectureElementKind.Container); + if (selected is null) + { + throw new ContractValidationException( + [ + new ContractError( + "c3.containerMissing", + "$.selectedContainerId", + "Selected C3 container must exist in the C2 base model."), + ]); + } + ArchitectureC3Workspace workspace = BuildMany( baseModel, [selectedContainerId]); From ee23bda9cd5f57b5649df1f0a287530aed2e4f77 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:34:56 -0300 Subject: [PATCH 31/74] docs: document multi-container C3 generation (#67) --- .github/workflows/ci.yml | 14 ++++++++++++++ CHANGELOG.md | 1 + README.md | 2 +- README.pt-BR.md | 2 +- docs/cli.md | 14 +++++++++++--- docs/cli.pt-BR.md | 14 +++++++++++--- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ab27b11..4c486da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,6 +405,20 @@ jobs: grep -Fq "component" "$workdir/c3/model.c4" "${cli[@]}" validate --output "$workdir/c3" + "${cli[@]}" generate \ + --model examples/models/multi-c3.c2.v1.json \ + --output "$workdir/c3-multi" \ + --c3-container el_beta \ + --c3-container el_alpha \ + --c3-container el_alpha \ + --apply + test -s "$workdir/c3-multi/c3.views.c4" + grep -Fq "view c3_el_alpha {" "$workdir/c3-multi/c3.views.c4" + grep -Fq "view c3_el_beta {" "$workdir/c3-multi/c3.views.c4" + test "$(grep -Ec '^[[:space:]]*view c3_' "$workdir/c3-multi/c3.views.c4")" -eq 2 + grep -Fq "component" "$workdir/c3-multi/model.c4" + "${cli[@]}" validate --output "$workdir/c3-multi" + preview="$("${cli[@]}" generate \ --model examples/end-to-end/architecture.c2.v1.json \ --output "$workdir/c2")" diff --git a/CHANGELOG.md b/CHANGELOG.md index 795a5cd9..cffc275b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Repeatable CLI `--c3-container` selection backed by one canonical multi-container C3 workspace, deterministic per-container LikeC4 views, duplicate-ID normalization, pre-write validation, and managed-output compatibility while preserving the single-container behavior. - Bounded Semantic C3 internal/external relation composition with explicit wiring+invocation confidence policy, deterministic edge deduplication, weak-cycle/cross-container safeguards, existing C1/C2 peer reuse, and a signal/missing-signal evidence report. - Reviewable Semantic C3 worker/messaging/persistence proposals that merge hosted-service evidence, distinguish supported publish/consume adapters through Integration Evidence, require observed use before promoting repository-shaped persistence, reuse existing C1/C2 external peers, and keep weak/unlinked evidence conservative. - Reviewable Semantic C3 HTTP/application proposals that aggregate routes by semantic boundary and distinguish DI-/handler-backed application services from name-only classes. diff --git a/README.md b/README.md index d7560d2e..78c3ef07 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Repo2C4 keeps AI orchestration outside Core and MCP. Core performs no AI calls, - **Bounded local inspection:** scans one explicitly authorized local root, skips mandatory sensitive/generated paths and linked path escapes, enforces file/byte/entry budgets, and does not execute repository code. - **Traceable .NET evidence:** extracts supported solution/project/source declarations with repository-relative provenance while keeping static declarations and runtime candidates distinct. - **Deterministic LikeC4:** generates reviewable C1/C2 workspaces and validates them with the separately installed official LikeC4 CLI. -- **Selective C3:** expands exactly one explicitly selected C2 container when enough evidence is available; other containers are not expanded automatically. +- **Selective multi-container C3:** expands one or more explicitly selected C2 containers when enough evidence is available; repeated selections share one deterministic workspace and unselected containers are not expanded automatically. - **Review-first writes:** `generate` previews by default. CLI writes require `--apply`; MCP writes require explicit `dryRun=false`, `write=true`, and an authorized relative destination. Managed-file hashes protect human edits. - **CLI:** `repo2c4` exposes onboarding, inspection, optional inference, generation and validation. - **MCP stdio server:** `repo2c4-mcp` exposes evidence, generation and validation tools inside one authorized root. Model selection, if any, belongs to the MCP client. diff --git a/README.pt-BR.md b/README.pt-BR.md index 58a59627..9824a9f4 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -32,7 +32,7 @@ O Repo2C4 mantém a orquestração de IA fora do Core e do MCP. O Core não faz - **Inspeção local limitada:** examina uma única raiz local explicitamente autorizada, ignora caminhos sensíveis/gerados obrigatórios e escapes por links, aplica budgets de arquivos/bytes/entradas e não executa código do repositório. - **Evidências .NET rastreáveis:** extrai declarações suportadas de solution/project/source com proveniência relativa ao repositório, mantendo declarações estáticas e candidatos de runtime semanticamente separados. - **LikeC4 determinístico:** gera workspaces C1/C2 revisáveis e os valida com a CLI oficial do LikeC4 instalada separadamente. -- **C3 seletivo:** expande exatamente um container C2 selecionado explicitamente quando há evidência suficiente; os demais containers não recebem C3 automaticamente. +- **C3 seletivo multi-container:** expande um ou mais containers C2 selecionados explicitamente quando há evidência suficiente; seleções repetidas compartilham um único workspace determinístico e containers não selecionados não recebem C3 automaticamente. - **Escrita review-first:** `generate` é preview por padrão. Na CLI, escrita exige `--apply`; no MCP exige `dryRun=false`, `write=true` e um destino relativo autorizado. Hashes dos arquivos gerenciados protegem edições humanas. - **CLI:** `repo2c4` oferece onboarding, inspeção, inferência opcional, geração e validação. - **Servidor MCP stdio:** `repo2c4-mcp` expõe evidências, geração e validação dentro de uma raiz autorizada. A seleção de modelo, quando existir, pertence ao cliente MCP. diff --git a/docs/cli.md b/docs/cli.md index c48bef72..9ac9efc1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,13 +61,21 @@ The first successful apply creates `.repo2c4-manifest.json`, which stores the mo `evidence-report.md` maps model assertions to evidence IDs and repository-relative locations, lists hypotheses, scan warnings and missing origins, and omits source bodies and sensitive values. See [evidence report and architectural review](evidence-report.md). -To request C3 for exactly one reviewed C2 container, add `--c3-container ID`: +To request C3 for one or more reviewed C2 containers, repeat `--c3-container ID`: ```bash -repo2c4 generate --model architecture.c2.json --output DIR --c3-container el_web +repo2c4 generate \ + --model architecture.c2.json \ + --output DIR \ + --c3-container el_web \ + --c3-container el_worker ``` -Without this option no C3 files are produced. A valid selection nests reviewed component proposals inside the selected container in `model.c4` and adds `c3.views.c4`; the remaining C2 containers do not receive component views automatically. The C3 proposal is bounded, keeps candidate/static signals under review, and fails when the selected container has insufficient evidence. +A single `--c3-container` remains backward compatible with the previous behavior. With multiple selections, Repo2C4 builds one canonical workspace over the same C2 base model/snapshot, nests each component set under its selected container in one `model.c4`, and writes one `c3.views.c4` containing one deterministic C3 view per selected container. Argument order does not affect output and repeated container IDs are deduplicated. + +Without this option no C3 file is produced. Unknown IDs, actors/software systems passed as C3 selections, and C3 requested for a C1 model fail as invalid data before managed output is previewed or written. A selected container with insufficient C3 evidence also fails rather than fabricating components. + +Changing the selected-container set is treated as a normal managed-output modification when current files still match the manifest. Existing manual edits remain conflict-protected and are never overwritten automatically. The output directory and managed files must remain inside the selected root and must not be symlink, junction or reparse points. Generated filenames are fixed by Repo2C4 and cannot be supplied by model content. Writes are prepared in a transaction directory and the manifest is replaced only after all output files have been prepared; a failed commit restores previously managed files on a best-effort basis and never deletes unknown files. diff --git a/docs/cli.pt-BR.md b/docs/cli.pt-BR.md index 4589b911..ee54c9b6 100644 --- a/docs/cli.pt-BR.md +++ b/docs/cli.pt-BR.md @@ -61,13 +61,21 @@ A primeira aplicação bem-sucedida cria `.repo2c4-manifest.json`, contendo a ve `evidence-report.md` relaciona as afirmações do modelo aos IDs de evidência e às localizações relativas do repositório, separa hipóteses pendentes, avisos de varredura e itens sem suporte, sem copiar corpos de código nem valores sensíveis. Consulte [relatório de evidências e revisão arquitetural](evidence-report.md). -Para solicitar C3 de exatamente um container C2 revisado, adicione `--c3-container ID`: +Para solicitar C3 de um ou mais containers C2 revisados, repita `--c3-container ID`: ```bash -repo2c4 generate --model architecture.c2.json --output DIR --c3-container el_web +repo2c4 generate \ + --model architecture.c2.json \ + --output DIR \ + --c3-container el_web \ + --c3-container el_worker ``` -Sem essa opção nenhum arquivo C3 é gerado. Uma seleção válida aninha as propostas de componentes revisáveis dentro do container selecionado em `model.c4` e adiciona `c3.views.c4`; os demais containers C2 não recebem vistas de componentes automaticamente. A proposta C3 é limitada, mantém sinais estáticos/candidatos sob revisão e falha quando o container selecionado não possui evidência suficiente. +Uma única ocorrência de `--c3-container` permanece compatível com o comportamento anterior. Com múltiplas seleções, o Repo2C4 constrói um único workspace canônico sobre o mesmo modelo/snapshot C2, aninha cada conjunto de componentes no seu container em um único `model.c4` e grava um único `c3.views.c4` com uma view C3 determinística por container selecionado. A ordem dos argumentos não altera a saída e IDs repetidos são deduplicados. + +Sem essa opção nenhum arquivo C3 é gerado. IDs desconhecidos, actors/software systems usados como seleção C3 e C3 solicitado sobre um modelo C1 falham como dados inválidos antes do preview ou da escrita gerenciada. Um container selecionado sem evidência C3 suficiente também falha em vez de fabricar componentes. + +Alterar o conjunto de containers selecionados é tratado como uma modificação normal das saídas gerenciadas quando os arquivos atuais ainda correspondem ao manifesto. Edições manuais existentes continuam protegidas como conflito e nunca são sobrescritas automaticamente. O diretório de saída e os arquivos gerenciados precisam permanecer dentro da raiz escolhida e não podem ser symlink, junction ou reparse point. Os nomes gerados são fixos pelo Repo2C4 e não podem ser definidos pelo conteúdo do modelo. As escritas são preparadas em um diretório transacional; o manifesto só é substituído após a preparação das saídas. Em caso de falha, arquivos previamente gerenciados são restaurados em best effort e arquivos desconhecidos nunca são excluídos. From ff55313b552f35e1d57d78a7481594086b9ec808 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:51:06 -0300 Subject: [PATCH 32/74] feat: expose bounded multi-container C3 over MCP (#68) --- src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs | 30 ++++++ src/Repo2C4.Mcp/McpLikeC4Tools.cs | 119 ++++++++++++++++++++--- src/Repo2C4.Mcp/McpLimits.cs | 3 + src/Repo2C4.Mcp/McpToolContracts.cs | 10 +- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs b/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs index e39027ff..52626f7c 100644 --- a/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs +++ b/src/Repo2C4.Core/LikeC4/LikeC4Emitter.cs @@ -7,6 +7,9 @@ namespace Repo2C4.Core.LikeC4; /// A generated LikeC4 source file kept entirely in memory. public sealed record LikeC4GeneratedFile(string FileName, string Content); +/// Stable descriptor for one emitted C3 view and its selected C2 container. +public sealed record LikeC4C3View(string ContainerId, string ViewId); + /// Pure, deterministic emission of a validated v1 architecture model into LikeC4 source files. public static class LikeC4Emitter { @@ -131,6 +134,33 @@ .. workspace.Selections ]; } + public static ImmutableArray DescribeC3Views( + ArchitectureC3Workspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + + ImmutableArray errors = + ArchitectureC3WorkspaceValidator.Validate(workspace); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + ArchitectureC3Selection[] selections = + [ + .. workspace.Selections + .OrderBy(selection => selection.SelectedContainerId, StringComparer.Ordinal), + ]; + Dictionary identifiers = BuildC3ViewIdentifiers(selections); + + return + [ + .. selections.Select(selection => new LikeC4C3View( + selection.SelectedContainerId, + identifiers[selection.SelectedContainerId])), + ]; + } + private static Dictionary BuildComponentIdentifiers( ArchitectureC3Selection[] selections, Dictionary references) diff --git a/src/Repo2C4.Mcp/McpLikeC4Tools.cs b/src/Repo2C4.Mcp/McpLikeC4Tools.cs index 8a414a98..bed8c4d9 100644 --- a/src/Repo2C4.Mcp/McpLikeC4Tools.cs +++ b/src/Repo2C4.Mcp/McpLikeC4Tools.cs @@ -35,8 +35,9 @@ internal void AddTools(McpServerPrimitiveCollection tools) "dryRun defaults to true and performs no writes. Writing requires dryRun=false, write=true and an explicit " + "repository-relative destinationPath inside the authorized root; managed outputs use a manifest, preview/diff and conflict protection. " + "The model snapshot must exactly match snapshotId and unsupported static/candidate evidence cannot be promoted " + - "to a confirmed container boundary or runtime relation. Optional c3ContainerId adds a bounded C3 view only for " + - "that existing C2 container; omission preserves C1/C2 behavior.", + "to a confirmed container boundary or runtime relation. Optional c3Containers selects up to " + + McpLimits.MaxC3ContainersPerCall + " C2 containers for one bounded C3 workspace. The legacy c3ContainerId remains supported; " + + "omission preserves C1/C2 behavior.", readOnly: false, idempotent: false)); @@ -54,7 +55,7 @@ internal void AddTools(McpServerPrimitiveCollection tools) "validate_likec4", "Validate deterministic LikeC4 with the controlled official LikeC4 CLI adapter. " + "When destinationPath is omitted, the supplied v1 ArchitectureModel is emitted into an isolated temporary workspace " + - "and validated without repository writes; optional c3ContainerId selects the same C3 proposal as generate_likec4. " + + "and validated without repository writes; optional c3Containers (or legacy c3ContainerId) selects the same C3 proposal as generate_likec4. " + "When destinationPath is supplied, it must be an existing directory inside the authorized root and its existing files " + "are validated. Validation failures are returned as structured bounded diagnostics.", readOnly: true, @@ -73,8 +74,10 @@ public async Task GenerateLikeC4( bool write = false, [Description("Repository-relative destination directory inside the authorized root. Required only for writing.")] string? destinationPath = null, - [Description("Optional C2 container ID. When supplied, only that container receives an additional C3 component view.")] + [Description("Legacy optional C2 container ID. Prefer c3Containers for multi-container C3.")] string? c3ContainerId = null, + [Description("Optional bounded collection of C2 container IDs for one deterministic multi-container C3 workspace.")] + string[]? c3Containers = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -98,7 +101,8 @@ public async Task GenerateLikeC4( "destination_required: writing requires an explicit repository-relative destinationPath."); } - IReadOnlyList generated = EmitForSelection(model, c3ContainerId); + McpC3Emission emission = EmitForSelection(model, c3ContainerId, c3Containers); + IReadOnlyList generated = emission.Files; McpLikeC4File[] files = [ .. generated.Select(file => new McpLikeC4File( @@ -146,7 +150,8 @@ or NotSupportedException NormalizeDestinationForResponse(destinationPath), files, changes, - plan?.HasConflicts ?? false)); + plan?.HasConflicts ?? false, + emission.Views)); } if (plan is null || outputRoot is null) @@ -192,7 +197,8 @@ or NotSupportedException NormalizeDestinationForResponse(destinationPath), files, [.. plan.Changes.Select(ToMcpChange)], - false)); + false, + emission.Views)); } [Description("Return a metadata-only evidence provenance report for a session-bound v1 model.")] @@ -248,8 +254,10 @@ public async Task ValidateLikeC4( ArchitectureModel model, [Description("Optional existing repository-relative LikeC4 directory. Omit to validate the proposed model in a temporary workspace.")] string? destinationPath = null, - [Description("Optional C2 container ID to include the same proposed C3 output as generate_likec4 when validating without destinationPath.")] + [Description("Legacy optional C2 container ID. Prefer c3Containers for multi-container C3.")] string? c3ContainerId = null, + [Description("Optional bounded collection of C2 container IDs for proposal validation when destinationPath is omitted.")] + string[]? c3Containers = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -258,10 +266,13 @@ public async Task ValidateLikeC4( string workspace; string workspaceLabel; string? temporaryWorkspace = null; + McpC3View[] c3Views = []; if (string.IsNullOrWhiteSpace(destinationPath)) { - IReadOnlyList generated = EmitForSelection(model, c3ContainerId); + McpC3Emission emission = EmitForSelection(model, c3ContainerId, c3Containers); + IReadOnlyList generated = emission.Files; + c3Views = emission.Views; temporaryWorkspace = Directory.CreateTempSubdirectory("repo2c4-mcp-likec4-").FullName; workspace = temporaryWorkspace; workspaceLabel = "proposed"; @@ -317,7 +328,8 @@ or NotSupportedException result.IsValid, result.ExitCode, result.TimedOut, - [.. result.Diagnostics])); + [.. result.Diagnostics], + c3Views)); } finally { @@ -328,19 +340,53 @@ or NotSupportedException } } - private static IReadOnlyList EmitForSelection( + private static McpC3Emission EmitForSelection( ArchitectureModel model, - string? c3ContainerId) + string? c3ContainerId, + IReadOnlyList? c3Containers) { try { - if (string.IsNullOrWhiteSpace(c3ContainerId)) + string[] selected = NormalizeC3Selections(c3ContainerId, c3Containers); + if (selected.Length == 0) + { + return new McpC3Emission( + LikeC4Emitter.Emit(model), + []); + } + + ArchitectureC3Workspace workspace; + IReadOnlyList files; + + if ((c3Containers is null || c3Containers.Count == 0) && + !string.IsNullOrWhiteSpace(c3ContainerId)) + { + ArchitectureC3Model c3 = ArchitectureC3Builder.Build(model, c3ContainerId); + workspace = new ArchitectureC3Workspace( + c3.SchemaVersion, + model, + [ + new ArchitectureC3Selection( + c3.SelectedContainerId, + c3.Components, + c3.Relations), + ]); + EnsureC3Budget(workspace); + files = LikeC4Emitter.EmitWithC3(model, c3); + } + else { - return LikeC4Emitter.Emit(model); + workspace = ArchitectureC3Builder.BuildMany(model, selected); + EnsureC3Budget(workspace); + files = LikeC4Emitter.EmitWithC3(workspace); } - ArchitectureC3Model c3 = ArchitectureC3Builder.Build(model, c3ContainerId); - return LikeC4Emitter.EmitWithC3(model, c3); + McpC3View[] views = + [ + .. LikeC4Emitter.DescribeC3Views(workspace) + .Select(view => new McpC3View(view.ContainerId, view.ViewId)), + ]; + return new McpC3Emission(files, views); } catch (ContractValidationException exception) { @@ -351,6 +397,47 @@ private static IReadOnlyList EmitForSelection( } } + private static string[] NormalizeC3Selections( + string? c3ContainerId, + IReadOnlyList? c3Containers) + { + int collectionCount = c3Containers?.Count ?? 0; + int requestedCount = + collectionCount + (string.IsNullOrWhiteSpace(c3ContainerId) ? 0 : 1); + if (requestedCount > McpLimits.MaxC3ContainersPerCall) + { + throw new McpException( + "c3_budget_exceeded: at most " + + McpLimits.MaxC3ContainersPerCall + + " C3 container selections are allowed per call."); + } + + return + [ + .. (c3Containers ?? []) + .Concat(string.IsNullOrWhiteSpace(c3ContainerId) ? [] : [c3ContainerId]) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + } + + private static void EnsureC3Budget(ArchitectureC3Workspace workspace) + { + int components = workspace.Selections.Sum(selection => selection.Components.Length); + int relations = workspace.Selections.Sum(selection => selection.Relations.Length); + + if (components > McpLimits.MaxC3ComponentsPerCall || + relations > McpLimits.MaxC3RelationsPerCall) + { + throw new McpException( + "c3_budget_exceeded: C3 workspace exceeds the bounded component/relation budget."); + } + } + + private sealed record McpC3Emission( + IReadOnlyList Files, + McpC3View[] Views); + private McpSnapshotStore.SnapshotEntry ValidateModelBinding( string snapshotId, ArchitectureModel model) diff --git a/src/Repo2C4.Mcp/McpLimits.cs b/src/Repo2C4.Mcp/McpLimits.cs index fbdb0f53..ff884530 100644 --- a/src/Repo2C4.Mcp/McpLimits.cs +++ b/src/Repo2C4.Mcp/McpLimits.cs @@ -10,6 +10,9 @@ internal static class McpLimits internal const int MaxPageSize = 100; internal const int DefaultPageSize = 50; internal const int MaxSummaryItems = 20; + internal const int MaxC3ContainersPerCall = 8; + internal const int MaxC3ComponentsPerCall = 256; + internal const int MaxC3RelationsPerCall = 512; internal const int MaxResponseBytes = 1_048_576; internal const int ProtocolEnvelopeReserveBytes = 4_096; } diff --git a/src/Repo2C4.Mcp/McpToolContracts.cs b/src/Repo2C4.Mcp/McpToolContracts.cs index cffbecc4..41d39868 100644 --- a/src/Repo2C4.Mcp/McpToolContracts.cs +++ b/src/Repo2C4.Mcp/McpToolContracts.cs @@ -46,6 +46,10 @@ public sealed record McpLikeC4File( string Content, int Utf8Bytes); +public sealed record McpC3View( + string ContainerId, + string ViewId); + public sealed class CamelCaseGeneratedChangeKindConverter : JsonConverter { public override GeneratedFileChangeKind Read( @@ -88,7 +92,8 @@ public sealed record McpGenerateLikeC4Result( string? DestinationPath, McpLikeC4File[] Files, McpGeneratedFileChange[] Changes, - bool HasConflicts); + bool HasConflicts, + McpC3View[] C3Views); public sealed record McpValidateLikeC4Result( string SnapshotId, @@ -97,4 +102,5 @@ public sealed record McpValidateLikeC4Result( bool IsValid, int ExitCode, bool TimedOut, - Repo2C4.Core.LikeC4.LikeC4ValidationDiagnostic[] Diagnostics); + Repo2C4.Core.LikeC4.LikeC4ValidationDiagnostic[] Diagnostics, + McpC3View[] C3Views); From 43f8e294411daad715f99b3040497e05cbff6339 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:53:15 -0300 Subject: [PATCH 33/74] feat: authorize multi-container C3 in Agent workflow (#68) --- src/Repo2C4.Agent/AgentHostOptions.cs | 60 ++++-- src/Repo2C4.Agent/AgentMcpSession.cs | 171 ++++++++++++++---- src/Repo2C4.Agent/AgentWriteApproval.cs | 36 +++- .../ArchitectureAnalysisWorkflow.cs | 8 + src/Repo2C4.Agent/EvidenceFirstAnalysis.cs | 37 ++-- src/Repo2C4.Agent/Program.cs | 4 +- 6 files changed, 248 insertions(+), 68 deletions(-) diff --git a/src/Repo2C4.Agent/AgentHostOptions.cs b/src/Repo2C4.Agent/AgentHostOptions.cs index f5dd2eb9..a0138a42 100644 --- a/src/Repo2C4.Agent/AgentHostOptions.cs +++ b/src/Repo2C4.Agent/AgentHostOptions.cs @@ -6,6 +6,7 @@ public sealed record AgentHostOptions(string Provider, string Model, string? Pro public const string OllamaProvider = "ollama"; public const string OpenAiProvider = "openai"; public const string OpenAiApiKeyEnvironmentVariable = "OPENAI_API_KEY"; + public const int MaxC3ContainersPerRun = 8; public static readonly Uri DefaultOllamaEndpoint = new("http://127.0.0.1:11434/"); @@ -59,6 +60,20 @@ public string? C3ContainerId init; } + public IReadOnlyList C3ContainerIds + { + get; + init; + } = []; + + public IReadOnlyList AuthorizedC3ContainerIds => + [ + .. C3ContainerIds + .Concat(string.IsNullOrWhiteSpace(C3ContainerId) ? [] : [C3ContainerId]) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + public int MaxValidationAttempts { get; @@ -122,7 +137,7 @@ public static bool TryParse( string? repositoryRoot = null; string? integrationReportPath = null; string? mcpServerPath = null; - string? c3ContainerId = null; + List c3ContainerIds = []; string? writeDestination = null; int timeoutSeconds = 90; int maxDurationSeconds = 300; @@ -252,19 +267,26 @@ public static bool TryParse( break; case "--c3-container": - if (!TryReadUniqueValue( - args, - ref index, - c3ContainerId, - "--c3-container", - out string? c3ContainerValue, - out error)) + if (!TryReadValue(args, ref index, out string? c3ContainerValue)) { - options = null; - return false; + return Fail( + "--c3-container requires a non-empty value.", + out options, + out error); + } + + if (c3ContainerIds.Count >= MaxC3ContainersPerRun) + { + return Fail( + "--c3-container may be specified at most " + + MaxC3ContainersPerRun.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + " times.", + out options, + out error); } - c3ContainerId = c3ContainerValue; + c3ContainerIds.Add(c3ContainerValue); break; case "--write-destination": @@ -497,8 +519,14 @@ public static bool TryParse( } } - string? configuredC3ContainerId = c3ContainerId?.Trim(); - if (configuredC3ContainerId is not null && !IsValidArchitectureId(configuredC3ContainerId)) + string[] configuredC3ContainerIds = + [ + .. c3ContainerIds + .Select(id => id.Trim()) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + if (configuredC3ContainerIds.Any(id => !IsValidArchitectureId(id))) { return Fail( "--c3-container must be a valid lowercase architecture element ID.", @@ -506,6 +534,11 @@ public static bool TryParse( out error); } + string? configuredC3ContainerId = + configuredC3ContainerIds.Length == 1 + ? configuredC3ContainerIds[0] + : null; + string? configuredWriteDestination = writeDestination?.Trim(); if (configuredWriteDestination is not null && !TryNormalizeRelativeDestination( @@ -557,6 +590,7 @@ public static bool TryParse( IntegrationReportPath = configuredIntegrationReportPath, McpServerPath = mcpServerPath?.Trim(), C3ContainerId = configuredC3ContainerId, + C3ContainerIds = configuredC3ContainerIds, MaxValidationAttempts = maxValidationAttempts, MaxToolCalls = maxToolCalls, MaxWorkflowIterations = maxWorkflowIterations, diff --git a/src/Repo2C4.Agent/AgentMcpSession.cs b/src/Repo2C4.Agent/AgentMcpSession.cs index 49bfb43a..8be9aa06 100644 --- a/src/Repo2C4.Agent/AgentMcpSession.cs +++ b/src/Repo2C4.Agent/AgentMcpSession.cs @@ -57,7 +57,14 @@ public sealed record AgentArchitectureProposal( string Level, string SnapshotId, JsonElement Model, - string? C3ContainerId); + string? C3ContainerId) +{ + public IReadOnlyList C3ContainerIds + { + get; + init; + } = string.IsNullOrWhiteSpace(C3ContainerId) ? [] : [C3ContainerId]; +} /// Run-local capture of successful preview calls made through the safe MCP boundary. public sealed class AgentMcpInvocationState @@ -94,10 +101,19 @@ public void BeginAttempt() public void RecordPreview( string snapshotId, JsonElement model, - string? c3ContainerId) + string? c3ContainerId, + IReadOnlyList? c3ContainerIds = null) { ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); + string[] selectedC3Containers = + [ + .. (c3ContainerIds ?? []) + .Concat(string.IsNullOrWhiteSpace(c3ContainerId) ? [] : [c3ContainerId]) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + string level = model.ValueKind == JsonValueKind.Object && model.TryGetProperty("level", out JsonElement levelElement) @@ -109,7 +125,10 @@ public void RecordPreview( level, snapshotId, model.Clone(), - c3ContainerId); + selectedC3Containers.Length == 1 ? selectedC3Containers[0] : null) + { + C3ContainerIds = selectedC3Containers, + }; lock (gate) { @@ -289,7 +308,8 @@ public async ValueTask CreateAsync( discovered, options.C3ContainerId, invocationState, - options.IntegrationReportPath); + options.IntegrationReportPath, + options.C3ContainerIds); McpClientTool generateLikeC4 = discovered.Single( tool => string.Equals( tool.Name, @@ -460,10 +480,19 @@ public static IReadOnlyList CreateSafeTools( IEnumerable discovered, string? authorizedC3ContainerId, AgentMcpInvocationState? invocationState = null, - string? authorizedIntegrationReportPath = null) + string? authorizedIntegrationReportPath = null, + IReadOnlyList? authorizedC3ContainerIds = null) { ArgumentNullException.ThrowIfNull(discovered); + string[] authorizedC3Containers = + [ + .. (authorizedC3ContainerIds ?? []) + .Concat(string.IsNullOrWhiteSpace(authorizedC3ContainerId) ? [] : [authorizedC3ContainerId]) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + List tools = []; foreach (AITool tool in discovered) { @@ -485,11 +514,11 @@ public static IReadOnlyList CreateSafeTools( authorizedIntegrationReportPath), "generate_likec4" => new PreviewOnlyMcpFunction( function, - authorizedC3ContainerId, + authorizedC3Containers, invocationState), "validate_likec4" => new ProposalOnlyValidationMcpFunction( function, - authorizedC3ContainerId), + authorizedC3Containers), _ => function, }; @@ -539,14 +568,14 @@ private sealed class AuthorizedRootInspectionMcpFunction( private sealed class PreviewOnlyMcpFunction( AIFunction inner, - string? authorizedC3ContainerId, + string[] authorizedC3Containers, AgentMcpInvocationState? invocationState) : DelegatingAIFunction(inner) { public override string Description => - authorizedC3ContainerId is null + authorizedC3Containers.Length == 0 ? "Preview deterministic LikeC4. This Agent wrapper forces dryRun=true, write=false, ignores destinationPath and disables C3." - : "Preview deterministic LikeC4. This Agent wrapper forces dryRun=true, write=false, ignores destinationPath and permits C3 only for the user-selected container."; + : "Preview deterministic LikeC4. This Agent wrapper forces dryRun=true, write=false, ignores destinationPath and permits only an explicitly requested subset of the host-authorized C3 containers."; protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, @@ -555,32 +584,26 @@ authorizedC3ContainerId is null ArgumentNullException.ThrowIfNull(arguments); AIFunctionArguments safeArguments = []; - bool requestedC3 = false; foreach ((string key, object? value) in arguments) { - if (key is "dryRun" or "write" or "destinationPath") - { - continue; - } - - if (key == "c3ContainerId") + if (key is "dryRun" or "write" or "destinationPath" or "c3ContainerId" or "c3Containers") { - requestedC3 = value is not null; continue; } safeArguments[key] = value; } - string? safeC3ContainerId = - requestedC3 && authorizedC3ContainerId is not null - ? authorizedC3ContainerId - : null; + C3RequestSelection selection = SelectAuthorizedC3( + arguments, + authorizedC3Containers); safeArguments["dryRun"] = true; safeArguments["write"] = false; safeArguments["destinationPath"] = null; - safeArguments["c3ContainerId"] = safeC3ContainerId; + safeArguments["c3ContainerId"] = selection.LegacyContainerId; + safeArguments["c3Containers"] = + selection.ContainerIds.Length == 0 ? null : selection.ContainerIds; object? result = await base .InvokeCoreAsync(safeArguments, cancellationToken) @@ -594,7 +617,11 @@ authorizedC3ContainerId is null && TryGetJsonElement(modelValue, out JsonElement model) && model.ValueKind == JsonValueKind.Object) { - invocationState.RecordPreview(snapshotId, model, safeC3ContainerId); + invocationState.RecordPreview( + snapshotId, + model, + selection.LegacyContainerId, + selection.ContainerIds); } return result; @@ -603,11 +630,11 @@ authorizedC3ContainerId is null private sealed class ProposalOnlyValidationMcpFunction( AIFunction inner, - string? authorizedC3ContainerId) + string[] authorizedC3Containers) : DelegatingAIFunction(inner) { public override string Description => - "Validate only an in-memory proposal. This Agent wrapper ignores destinationPath and permits C3 only for the user-selected container."; + "Validate only an in-memory proposal. This Agent wrapper ignores destinationPath and permits only an explicitly requested subset of the host-authorized C3 containers."; protected override ValueTask InvokeCoreAsync( AIFunctionArguments arguments, @@ -616,33 +643,101 @@ private sealed class ProposalOnlyValidationMcpFunction( ArgumentNullException.ThrowIfNull(arguments); AIFunctionArguments safeArguments = []; - bool requestedC3 = false; foreach ((string key, object? value) in arguments) { - if (key == "destinationPath") + if (key is "destinationPath" or "c3ContainerId" or "c3Containers") { continue; } - if (key == "c3ContainerId") - { - requestedC3 = value is not null; - continue; - } - safeArguments[key] = value; } + C3RequestSelection selection = SelectAuthorizedC3( + arguments, + authorizedC3Containers); safeArguments["destinationPath"] = null; - safeArguments["c3ContainerId"] = - requestedC3 && authorizedC3ContainerId is not null - ? authorizedC3ContainerId - : null; + safeArguments["c3ContainerId"] = selection.LegacyContainerId; + safeArguments["c3Containers"] = + selection.ContainerIds.Length == 0 ? null : selection.ContainerIds; return base.InvokeCoreAsync(safeArguments, cancellationToken); } } + private static C3RequestSelection SelectAuthorizedC3( + AIFunctionArguments arguments, + IReadOnlyList authorized) + { + string[] requested = ReadRequestedC3(arguments); + HashSet allowed = authorized.ToHashSet(StringComparer.Ordinal); + string[] selected = + [ + .. requested + .Where(allowed.Contains) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ]; + + bool legacyOnly = + arguments.TryGetValue("c3ContainerId", out object? legacyValue) + && legacyValue is not null + && !arguments.TryGetValue("c3Containers", out object? multiValue); + + if (legacyOnly && authorized.Count == 1 && selected.Length == 0) + { + selected = [authorized[0]]; + } + + return new C3RequestSelection( + legacyOnly && selected.Length == 1 ? selected[0] : null, + selected); + } + + private static string[] ReadRequestedC3(AIFunctionArguments arguments) + { + List requested = []; + + if (arguments.TryGetValue("c3ContainerId", out object? legacy) && + legacy is string legacyId && + !string.IsNullOrWhiteSpace(legacyId)) + { + requested.Add(legacyId); + } + + if (arguments.TryGetValue("c3Containers", out object? multiple) && + multiple is not null) + { + switch (multiple) + { + case JsonElement element when element.ValueKind == JsonValueKind.Array: + requested.AddRange( + element.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Cast()); + break; + + case IEnumerable strings: + requested.AddRange(strings.Where(id => !string.IsNullOrWhiteSpace(id))); + break; + + case IEnumerable objects: + requested.AddRange( + objects.OfType() + .Where(id => !string.IsNullOrWhiteSpace(id))); + break; + } + } + + return [.. requested]; + } + + private sealed record C3RequestSelection( + string? LegacyContainerId, + string[] ContainerIds); + private static bool IsMcpErrorResult(object? result) => result is JsonElement element && element.ValueKind == JsonValueKind.Object diff --git a/src/Repo2C4.Agent/AgentWriteApproval.cs b/src/Repo2C4.Agent/AgentWriteApproval.cs index 55544238..bca76c7b 100644 --- a/src/Repo2C4.Agent/AgentWriteApproval.cs +++ b/src/Repo2C4.Agent/AgentWriteApproval.cs @@ -26,7 +26,14 @@ public sealed record AgentWritePlanItem( IReadOnlyList Changes, IReadOnlyList RequiresReviewIds, string PreviewFingerprint, - bool HasConflicts); + bool HasConflicts) +{ + public IReadOnlyList C3ContainerIds + { + get; + init; + } = string.IsNullOrWhiteSpace(C3ContainerId) ? [] : [C3ContainerId]; +} public sealed record AgentWriteApprovalPlan( string DestinationRoot, @@ -123,7 +130,10 @@ public async ValueTask ApplyAsync( approved.Level, approved.SnapshotId, approved.Model, - approved.C3ContainerId); + approved.C3ContainerId) + { + C3ContainerIds = approved.C3ContainerIds, + }; AgentWritePlanItem current = await PreviewAsync( proposal, @@ -174,6 +184,10 @@ public async ValueTask ApplyAsync( ["write"] = true, ["destinationPath"] = approved.DestinationPath, ["c3ContainerId"] = approved.C3ContainerId, + ["c3Containers"] = + approved.C3ContainerIds.Count == 0 + ? null + : approved.C3ContainerIds, }, cancellationToken).ConfigureAwait(false); @@ -304,6 +318,10 @@ private async ValueTask PreviewAsync( ["write"] = false, ["destinationPath"] = destinationPath, ["c3ContainerId"] = proposal.C3ContainerId, + ["c3Containers"] = + proposal.C3ContainerIds.Count == 0 + ? null + : proposal.C3ContainerIds, }, cancellationToken).ConfigureAwait(false); @@ -356,7 +374,10 @@ .. content.GetProperty("changes") changes, GetRequiresReviewIds(proposal.Model), fingerprint, - hasConflicts); + hasConflicts) + { + C3ContainerIds = proposal.C3ContainerIds, + }; } private static string CombineDestination(string root, string level) => @@ -407,7 +428,7 @@ private static string ComputeFingerprint( + "\n" + proposal.Level + "\n" - + (proposal.C3ContainerId ?? string.Empty) + + string.Join(",", proposal.C3ContainerIds) + "\n" + destinationPath + "\n" @@ -469,6 +490,13 @@ await output.WriteLineAsync( await output.WriteLineAsync( item.Level + " -> " + item.DestinationPath).ConfigureAwait(false); + if (item.C3ContainerIds.Count > 0) + { + await output.WriteLineAsync( + " C3 containers: " + string.Join(", ", item.C3ContainerIds)) + .ConfigureAwait(false); + } + foreach (AgentWriteChangePreview change in item.Changes) { await output.WriteLineAsync( diff --git a/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs b/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs index c5a608c6..0b999934 100644 --- a/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs +++ b/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs @@ -739,6 +739,10 @@ public async ValueTask PreviewAsync( ["write"] = false, ["destinationPath"] = null, ["c3ContainerId"] = proposal.C3ContainerId, + ["c3Containers"] = + proposal.C3ContainerIds.Count == 0 + ? null + : proposal.C3ContainerIds, }, cancellationToken).ConfigureAwait(false); @@ -806,6 +810,10 @@ public async ValueTask ValidateAsync( ["model"] = proposal.Model, ["destinationPath"] = null, ["c3ContainerId"] = proposal.C3ContainerId, + ["c3Containers"] = + proposal.C3ContainerIds.Count == 0 + ? null + : proposal.C3ContainerIds, }, cancellationToken).ConfigureAwait(false); diff --git a/src/Repo2C4.Agent/EvidenceFirstAnalysis.cs b/src/Repo2C4.Agent/EvidenceFirstAnalysis.cs index e29a9309..75605c5e 100644 --- a/src/Repo2C4.Agent/EvidenceFirstAnalysis.cs +++ b/src/Repo2C4.Agent/EvidenceFirstAnalysis.cs @@ -45,11 +45,7 @@ private static string BuildProposalPrompt( string attemptContext) { string inspectionCall = InspectionCall(options); - string c3Policy = options.C3ContainerId is null - ? "C3 is not authorized for this run. Do not send c3ContainerId to any tool." - : "C3 is authorized only for the explicitly selected container ID " - + JsonSerializer.Serialize(options.C3ContainerId) - + ". Request C3 only from a C2 proposal that contains that exact element as a container; otherwise omit C3."; + string c3Policy = C3Policy(options, correction: false); return """ Produce evidence-first Repo2C4 architecture proposals for this objective: @@ -93,11 +89,7 @@ public static string Build(AgentHostOptions options) ArgumentException.ThrowIfNullOrWhiteSpace(options.Goal); string inspectionCall = InspectionCall(options); - string c3Policy = options.C3ContainerId is null - ? "C3 is not authorized for this run. Do not send c3ContainerId to any tool." - : "C3 is authorized only for the explicitly selected container ID " - + JsonSerializer.Serialize(options.C3ContainerId) - + ". Request C3 only from a C2 proposal that contains that exact element as a container; otherwise explain why C3 cannot be proposed."; + string c3Policy = C3Policy(options, correction: true); return """ Perform one evidence-first Repo2C4 architecture analysis for the following user objective: @@ -135,10 +127,33 @@ Requires review Diagnostics/blockers Proposal - In Proposal, identify the C1/C2 previews produced and whether C3 was omitted, unavailable or previewed for the authorized selected container. + In Proposal, identify the C1/C2 previews produced and list the exact C3 container IDs requested, or explain why C3 was omitted/unavailable. """; } + private static string C3Policy( + AgentHostOptions options, + bool correction) + { + string[] authorized = [.. options.AuthorizedC3ContainerIds]; + if (authorized.Length == 0) + { + return "C3 is not authorized for this run. Do not send c3ContainerId or c3Containers to any tool."; + } + + string unavailable = correction + ? "explain why a requested C3 cannot be proposed" + : "omit unsupported C3 targets"; + return "C3 is authorized only for this bounded set of container IDs: " + + JsonSerializer.Serialize(authorized) + + ". Choose only the subset relevant to the objective and supported by the C2 evidence; never select all automatically. " + + "Send that explicit subset in c3Containers on the C2 generate_likec4 call. " + + "Do not include ambiguous or unsupported component boundaries; keep useful uncertainty requiresReview. " + + "If evidence is insufficient, " + + unavailable + + "."; + } + private static string InspectionCall(AgentHostOptions options) => options.IntegrationReportPath is null ? "inspect_repository using repositoryPath=\".\"" diff --git a/src/Repo2C4.Agent/Program.cs b/src/Repo2C4.Agent/Program.cs index e05c356e..f2f35cad 100644 --- a/src/Repo2C4.Agent/Program.cs +++ b/src/Repo2C4.Agent/Program.cs @@ -229,7 +229,7 @@ private static void WriteHelp(TextWriter standardError) { standardError.WriteLine("Repo2C4 Agent host"); standardError.WriteLine( - "Usage: repo2c4-agent --provider ollama|openai --model --repository-root --goal [--integration-report ] [--write-destination ] [--c3-container ] [--max-validation-attempts 1-3] [--max-duration-seconds 1-1800] [--max-tool-calls 1-100] [--max-workflow-iterations 1-3] [--max-evidence-pages 1-50] [--max-response-chars 1024-100000] [--max-context-chars 4096-200000] [--mcp-server-path ] [--timeout-seconds 1-300]"); + "Usage: repo2c4-agent --provider ollama|openai --model --repository-root --goal [--integration-report ] [--write-destination ] [--c3-container ...] [--max-validation-attempts 1-3] [--max-duration-seconds 1-1800] [--max-tool-calls 1-100] [--max-workflow-iterations 1-3] [--max-evidence-pages 1-50] [--max-response-chars 1024-100000] [--max-context-chars 4096-200000] [--mcp-server-path ] [--timeout-seconds 1-300]"); standardError.WriteLine( "Agent Framework Workflows orchestrates proposal -> evidence report -> dry-run preview -> validation with bounded validation attempts and workflow iterations."); standardError.WriteLine( @@ -237,7 +237,7 @@ private static void WriteHelp(TextWriter standardError) standardError.WriteLine( "Optional --write-destination enables a separate Agent Framework HITL approval after successful validation; C1/C2 are written under /c1 and /c2 only after explicit local approval."); standardError.WriteLine( - "C3 is disabled unless --c3-container selects one container; the host never allows the model to select a different C3 target."); + "C3 is disabled unless --c3-container authorizes one or more containers (maximum 8). Repeat the option for multiple candidates; the Agent must request only the evidence-supported subset and the host rejects escalation outside that set."); standardError.WriteLine( "If --mcp-server-path is omitted, the installed repo2c4-mcp command is used. A .dll path is launched with dotnet."); standardError.WriteLine( From 0351946fd4175c9bd9a96b32ff8802e7e6c78623 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:56:49 -0300 Subject: [PATCH 34/74] fix: satisfy nullable contract for repeated C3 options (#68) --- src/Repo2C4.Agent/AgentHostOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repo2C4.Agent/AgentHostOptions.cs b/src/Repo2C4.Agent/AgentHostOptions.cs index a0138a42..1cc2d07e 100644 --- a/src/Repo2C4.Agent/AgentHostOptions.cs +++ b/src/Repo2C4.Agent/AgentHostOptions.cs @@ -286,7 +286,7 @@ public static bool TryParse( out error); } - c3ContainerIds.Add(c3ContainerValue); + c3ContainerIds.Add(c3ContainerValue!); break; case "--write-destination": From de8480d93b83583db1826d9d765565e21292dba1 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:57:28 -0300 Subject: [PATCH 35/74] test: cover bounded multi-container C3 MCP behavior (#68) --- .../Repo2C4.Mcp.Tests/McpLikeC4ToolsTests.cs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/tests/Repo2C4.Mcp.Tests/McpLikeC4ToolsTests.cs b/tests/Repo2C4.Mcp.Tests/McpLikeC4ToolsTests.cs index dfce8358..f0f9f4cf 100644 --- a/tests/Repo2C4.Mcp.Tests/McpLikeC4ToolsTests.cs +++ b/tests/Repo2C4.Mcp.Tests/McpLikeC4ToolsTests.cs @@ -47,6 +47,7 @@ public async Task DryRunPreviewsFilesWithoutWriting() Assert.Equal(3, result.Files.Length); Assert.NotEmpty(result.Changes); Assert.False(result.HasConflicts); + Assert.Empty(result.C3Views); Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); } @@ -347,6 +348,201 @@ await Assert.ThrowsAnyAsync( Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); } + [Fact] + public async Task LegacySingleC3SelectionRemainsCompatibleAndReportsView() + { + using TempDirectory temp = new(); + using McpSnapshotStore store = new(); + RepositorySnapshot snapshot = CreateMultiC3Snapshot(); + McpSnapshotStore.SnapshotEntry entry = store.Store(snapshot); + McpLikeC4Tools tools = new(temp.Path, store); + + McpGenerateLikeC4Result result = await tools.GenerateLikeC4( + entry.SnapshotId, + CreateMultiC3Model(snapshot), + destinationPath: "architecture", + c3ContainerId: "el_alpha", + cancellationToken: TestContext.Current.CancellationToken); + + McpC3View view = Assert.Single(result.C3Views); + Assert.Equal("el_alpha", view.ContainerId); + Assert.Equal("c3", view.ViewId); + Assert.Contains(result.Files, file => file.FileName == "c3.views.c4"); + Assert.False(result.Written); + Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); + } + + [Fact] + public async Task MultiC3SelectionIsCanonicalDeduplicatedAndReportsProducedViews() + { + using TempDirectory temp = new(); + using McpSnapshotStore store = new(); + RepositorySnapshot snapshot = CreateMultiC3Snapshot(); + McpSnapshotStore.SnapshotEntry entry = store.Store(snapshot); + McpLikeC4Tools tools = new(temp.Path, store); + + McpGenerateLikeC4Result result = await tools.GenerateLikeC4( + entry.SnapshotId, + CreateMultiC3Model(snapshot), + destinationPath: "architecture", + c3Containers: ["el_beta", "el_alpha", "el_alpha"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + ["el_alpha", "el_beta"], + result.C3Views.Select(view => view.ContainerId)); + Assert.Equal( + ["c3_el_alpha", "c3_el_beta"], + result.C3Views.Select(view => view.ViewId)); + Assert.Equal(4, result.Files.Length); + Assert.Contains(result.Files, file => file.FileName == "c3.views.c4"); + Assert.False(result.Written); + Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); + } + + [Fact] + public async Task MultiC3InvalidIdFailsBeforeManagedOutput() + { + using TempDirectory temp = new(); + using McpSnapshotStore store = new(); + RepositorySnapshot snapshot = CreateMultiC3Snapshot(); + McpSnapshotStore.SnapshotEntry entry = store.Store(snapshot); + McpLikeC4Tools tools = new(temp.Path, store); + + Exception invalid = await Assert.ThrowsAnyAsync( + () => tools.GenerateLikeC4( + entry.SnapshotId, + CreateMultiC3Model(snapshot), + dryRun: false, + write: true, + destinationPath: "architecture", + c3Containers: ["el_alpha", "el_missing"], + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("model_invalid", invalid.Message, StringComparison.Ordinal); + Assert.Contains("c3.containerMissing", invalid.Message, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); + } + + [Fact] + public async Task MultiC3SelectionBudgetIsEnforcedBeforeWriting() + { + using TempDirectory temp = new(); + using McpSnapshotStore store = new(); + RepositorySnapshot snapshot = CreateMultiC3Snapshot(); + McpSnapshotStore.SnapshotEntry entry = store.Store(snapshot); + McpLikeC4Tools tools = new(temp.Path, store); + + string[] overBudget = + [ + "el_alpha", + "el_beta", + "el_alpha", + "el_beta", + "el_alpha", + "el_beta", + "el_alpha", + "el_beta", + "el_alpha", + ]; + + Exception budget = await Assert.ThrowsAnyAsync( + () => tools.GenerateLikeC4( + entry.SnapshotId, + CreateMultiC3Model(snapshot), + dryRun: false, + write: true, + destinationPath: "architecture", + c3Containers: overBudget, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("c3_budget_exceeded", budget.Message, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); + } + + [Fact] + public async Task MultiC3ProposalValidationReportsSameViewsWithoutWriting() + { + using TempDirectory temp = new(); + using McpSnapshotStore store = new(); + RepositorySnapshot snapshot = CreateMultiC3Snapshot(); + McpSnapshotStore.SnapshotEntry entry = store.Store(snapshot); + McpLikeC4Tools tools = new(temp.Path, store); + + McpValidateLikeC4Result result = await tools.ValidateLikeC4( + entry.SnapshotId, + CreateMultiC3Model(snapshot), + c3Containers: ["el_alpha", "el_beta"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + ["el_alpha", "el_beta"], + result.C3Views.Select(view => view.ContainerId)); + Assert.False(Directory.Exists(Path.Combine(temp.Path, "architecture"))); + } + + private static RepositorySnapshot CreateMultiC3Snapshot() + { + RepositoryFile alpha = new("src/Alpha/Alpha.csproj", 128, null); + RepositoryFile beta = new("src/Beta/Beta.csproj", 128, null); + Evidence alphaEvidence = new( + "ev_alpha_http", + "dotnet.runtime.http.candidate", + alpha.RelativePath, + 1, + EvidenceSourceType.ProjectFile, + "Alpha is an HTTP host candidate."); + Evidence betaEvidence = new( + "ev_beta_http", + "dotnet.runtime.http.candidate", + beta.RelativePath, + 1, + EvidenceSourceType.ProjectFile, + "Beta is an HTTP host candidate."); + + return new RepositorySnapshot( + ContractSchema.Version, + "repo_multi_c3_test", + [alpha, beta], + [alphaEvidence, betaEvidence], + []); + } + + private static ArchitectureModel CreateMultiC3Model(RepositorySnapshot snapshot) + { + ArchitectureElement system = new( + "el_system", + ArchitectureElementKind.SoftwareSystem, + "System", + null, + ["ev_alpha_http"], + ReviewStatus.RequiresReview, + "System boundary requires review."); + ArchitectureElement alpha = new( + "el_alpha", + ArchitectureElementKind.Container, + "Alpha API", + "el_system", + ["ev_alpha_http"], + ReviewStatus.RequiresReview, + "Deployment boundary requires review."); + ArchitectureElement beta = new( + "el_beta", + ArchitectureElementKind.Container, + "Beta API", + "el_system", + ["ev_beta_http"], + ReviewStatus.RequiresReview, + "Deployment boundary requires review."); + + return new ArchitectureModel( + ContractSchema.Version, + ArchitectureLevel.C2, + snapshot, + [system, alpha, beta], + []); + } + private static RepositorySnapshot CreateSnapshot(string category = "dotnet.project.kind") { RepositoryFile file = new("App.csproj", 128, null); From 3177674968c273ffeacc8ec468ca8829d16f3d4c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:58:07 -0300 Subject: [PATCH 36/74] test: cover Agent multi-C3 authorization policy (#68) --- .../EvidenceFirstAnalysisTests.cs | 120 ++++++++++++++++-- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs index 9fb08a39..da72db3d 100644 --- a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs +++ b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs @@ -80,6 +80,7 @@ public async Task HappyPathUsesEvidenceBeforeC1C2ProposalAndProducesSeparatedSum Assert.False(call.Write); Assert.Null(call.DestinationPath); Assert.Null(call.C3ContainerId); + Assert.Empty(call.C3ContainerIds); }); } @@ -134,6 +135,7 @@ public async Task MaliciousEvidenceCannotOverridePolicyEnableWriteOrSelectC3() Assert.False(generation.Write); Assert.Null(generation.DestinationPath); Assert.Null(generation.C3ContainerId); + Assert.Empty(generation.C3ContainerIds); Assert.True(chatClient.SawMaliciousToolData); Assert.True(chatClient.SawUntrustedDataPolicy); Assert.Contains("Requires review", response, StringComparison.Ordinal); @@ -165,6 +167,56 @@ await generate.InvokeAsync( Assert.False(generation.Write); Assert.Null(generation.DestinationPath); Assert.Equal("el_web", generation.C3ContainerId); + Assert.Equal(["el_web"], generation.C3ContainerIds); + } + + [Fact] + public async Task MultiC3PolicyAllowsOnlyExplicitAuthorizedSubset() + { + AnalysisToolHarness harness = new(); + AIFunction generate = Assert.IsAssignableFrom( + AgentMcpToolPolicy + .CreateSafeTools( + harness.CreateTools(), + authorizedC3ContainerId: null, + authorizedC3ContainerIds: ["el_alpha", "el_beta", "el_gamma"]) + .Single(tool => tool.Name == "generate_likec4")); + + await generate.InvokeAsync( + new AIFunctionArguments + { + ["snapshotId"] = "snap-1", + ["model"] = CreateC2Model(), + ["dryRun"] = false, + ["write"] = true, + ["destinationPath"] = "attacker-selected", + ["c3Containers"] = new[] { "el_beta", "el_attacker", "el_beta" }, + }, + TestContext.Current.CancellationToken); + + GenerationCall generation = Assert.Single(harness.GenerationCalls); + Assert.True(generation.DryRun); + Assert.False(generation.Write); + Assert.Null(generation.DestinationPath); + Assert.Null(generation.C3ContainerId); + Assert.Equal(["el_beta"], generation.C3ContainerIds); + } + + [Fact] + public void MultiC3PromptRequiresEvidenceSupportedSubsetAndExplicitReporting() + { + AgentHostOptions options = new("ollama", "model", "Document architecture") + { + C3ContainerIds = ["el_alpha", "el_beta", "el_unused"], + }; + + string prompt = EvidenceFirstAnalysisPrompt.BuildForWorkflow(options); + + Assert.Contains("c3Containers", prompt, StringComparison.Ordinal); + Assert.Contains("never select all automatically", prompt, StringComparison.Ordinal); + Assert.Contains("el_alpha", prompt, StringComparison.Ordinal); + Assert.Contains("el_beta", prompt, StringComparison.Ordinal); + Assert.Contains("el_unused", prompt, StringComparison.Ordinal); } [Fact] @@ -184,6 +236,25 @@ public void GoalAndC3SelectionAreExplicitAndValidated() Assert.NotNull(options); Assert.Equal("Document architecture", options.Goal); Assert.Equal("el_web", options.C3ContainerId); + Assert.Equal(["el_web"], options.AuthorizedC3ContainerIds); + + parsed = AgentHostOptions.TryParse( + [ + "--provider", "ollama", + "--model", "model", + "--goal", "Document architecture", + "--c3-container", "el_beta", + "--c3-container", "el_alpha", + "--c3-container", "el_beta", + ], + out options, + out error); + + Assert.True(parsed, error); + Assert.NotNull(options); + Assert.Null(options.C3ContainerId); + Assert.Equal(["el_alpha", "el_beta"], options.C3ContainerIds); + Assert.Equal(["el_alpha", "el_beta"], options.AuthorizedC3ContainerIds); parsed = AgentHostOptions.TryParse( [ @@ -299,12 +370,36 @@ public IReadOnlyList CreateTools() => (string snapshotId, JsonElement model) => GetEvidenceReport(snapshotId, model), "get_evidence_report"), AIFunctionFactory.Create( - (string snapshotId, JsonElement model, bool dryRun, bool write, string? destinationPath, string? c3ContainerId) => - GenerateLikeC4(snapshotId, model, dryRun, write, destinationPath, c3ContainerId), + ( + string snapshotId, + JsonElement model, + bool dryRun, + bool write, + string? destinationPath, + string? c3ContainerId, + string[]? c3Containers) => + GenerateLikeC4( + snapshotId, + model, + dryRun, + write, + destinationPath, + c3ContainerId, + c3Containers), "generate_likec4"), AIFunctionFactory.Create( - (string snapshotId, JsonElement model, string? destinationPath, string? c3ContainerId) => - ValidateLikeC4(snapshotId, model, destinationPath, c3ContainerId), + ( + string snapshotId, + JsonElement model, + string? destinationPath, + string? c3ContainerId, + string[]? c3Containers) => + ValidateLikeC4( + snapshotId, + model, + destinationPath, + c3ContainerId, + c3Containers), "validate_likec4"), ]; @@ -404,11 +499,17 @@ private object GenerateLikeC4( bool dryRun, bool write, string? destinationPath, - string? c3ContainerId) + string? c3ContainerId, + string[]? c3Containers) { Calls.Add("generate_likec4"); ProposedModels.Add(model.Clone()); - GenerationCalls.Add(new GenerationCall(dryRun, write, destinationPath, c3ContainerId)); + GenerationCalls.Add(new GenerationCall( + dryRun, + write, + destinationPath, + c3ContainerId, + c3Containers ?? [])); return new { snapshotId, @@ -422,11 +523,13 @@ private static object ValidateLikeC4( string snapshotId, JsonElement model, string? destinationPath, - string? c3ContainerId) + string? c3ContainerId, + string[]? c3Containers) { _ = model; _ = destinationPath; _ = c3ContainerId; + _ = c3Containers; return new { snapshotId, @@ -440,7 +543,8 @@ private sealed record GenerationCall( bool DryRun, bool Write, string? DestinationPath, - string? C3ContainerId); + string? C3ContainerId, + IReadOnlyList C3ContainerIds); private sealed class ScriptedAnalysisChatClient( JsonElement c1Model, From 862e8537da13a086ecf67ac8d60cc6aac95becfc Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 21:59:24 -0300 Subject: [PATCH 37/74] test: prepare deterministic real-MCP multi-C3 harness (#68) --- .../agent-multi-c3/src/Alpha/Alpha.csproj | 5 + .../agent-multi-c3/src/Alpha/Program.cs | 4 + .../agent-multi-c3/src/Beta/Beta.csproj | 5 + .../agent-multi-c3/src/Beta/Program.cs | 4 + tests/Repo2C4.Agent.Tests/AgentMcpTests.cs | 4 +- .../ScriptedAgentHarness.cs | 171 +++++++++++++++++- 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 examples/fixtures/agent-multi-c3/src/Alpha/Alpha.csproj create mode 100644 examples/fixtures/agent-multi-c3/src/Alpha/Program.cs create mode 100644 examples/fixtures/agent-multi-c3/src/Beta/Beta.csproj create mode 100644 examples/fixtures/agent-multi-c3/src/Beta/Program.cs diff --git a/examples/fixtures/agent-multi-c3/src/Alpha/Alpha.csproj b/examples/fixtures/agent-multi-c3/src/Alpha/Alpha.csproj new file mode 100644 index 00000000..b112c1a3 --- /dev/null +++ b/examples/fixtures/agent-multi-c3/src/Alpha/Alpha.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/examples/fixtures/agent-multi-c3/src/Alpha/Program.cs b/examples/fixtures/agent-multi-c3/src/Alpha/Program.cs new file mode 100644 index 00000000..664b79f5 --- /dev/null +++ b/examples/fixtures/agent-multi-c3/src/Alpha/Program.cs @@ -0,0 +1,4 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("/alpha", () => Results.Ok()); +app.Run(); diff --git a/examples/fixtures/agent-multi-c3/src/Beta/Beta.csproj b/examples/fixtures/agent-multi-c3/src/Beta/Beta.csproj new file mode 100644 index 00000000..b112c1a3 --- /dev/null +++ b/examples/fixtures/agent-multi-c3/src/Beta/Beta.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/examples/fixtures/agent-multi-c3/src/Beta/Program.cs b/examples/fixtures/agent-multi-c3/src/Beta/Program.cs new file mode 100644 index 00000000..be4bc38c --- /dev/null +++ b/examples/fixtures/agent-multi-c3/src/Beta/Program.cs @@ -0,0 +1,4 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("/beta", () => Results.Ok()); +app.Run(); diff --git a/tests/Repo2C4.Agent.Tests/AgentMcpTests.cs b/tests/Repo2C4.Agent.Tests/AgentMcpTests.cs index f75fa692..10f15c11 100644 --- a/tests/Repo2C4.Agent.Tests/AgentMcpTests.cs +++ b/tests/Repo2C4.Agent.Tests/AgentMcpTests.cs @@ -95,7 +95,8 @@ public async Task FailedPreviewIsNotCapturedAsArchitectureProposal() bool dryRun, bool write, string? destinationPath, - string? c3ContainerId) => + string? c3ContainerId, + string[]? c3Containers) => { _ = snapshotId; _ = model; @@ -103,6 +104,7 @@ public async Task FailedPreviewIsNotCapturedAsArchitectureProposal() _ = write; _ = destinationPath; _ = c3ContainerId; + _ = c3Containers; return JsonSerializer.SerializeToElement(new { isError = true, diff --git a/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs b/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs index b90585bd..aa85bad9 100644 --- a/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs +++ b/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs @@ -199,6 +199,7 @@ public static async Task CreateAsync( IReadOnlyList? validationResults = null, string? writeDestination = null, string? integrationReportPath = null, + IReadOnlyList? c3ContainerIds = null, CancellationToken cancellationToken = default) { string repositoryRoot = FindRepositoryRoot(); @@ -227,6 +228,7 @@ public static async Task CreateAsync( McpServerPath = mcpServerPath, WriteDestination = writeDestination, IntegrationReportPath = integrationReportPath, + C3ContainerIds = c3ContainerIds ?? [], MaxValidationAttempts = Math.Min( Math.Max(validationResults?.Count ?? 1, 1), 3), @@ -379,6 +381,76 @@ .. snapshot.GetProperty("evidence") }); } + public static JsonElement BuildMultiContainerC2(JsonElement snapshot) + { + string[] alphaEvidence = EvidenceIdsForPrefix(snapshot, "src/Alpha/"); + string[] betaEvidence = EvidenceIdsForPrefix(snapshot, "src/Beta/"); + string[] systemEvidence = + [ + .. alphaEvidence + .Concat(betaEvidence) + .Take(3), + ]; + + return JsonSerializer.SerializeToElement(new + { + schemaVersion = "1.0", + level = "C2", + snapshot, + elements = new object[] + { + new + { + id = "el_system", + kind = "softwareSystem", + name = "Multi C3 fixture", + evidenceIds = systemEvidence, + status = "requiresReview", + reviewReason = "Repository evidence does not fully establish the software-system boundary.", + }, + new + { + id = "el_alpha", + kind = "container", + name = "Alpha API", + parentId = "el_system", + evidenceIds = alphaEvidence, + status = "requiresReview", + reviewReason = "Static host evidence does not prove the deployment boundary.", + }, + new + { + id = "el_beta", + kind = "container", + name = "Beta API", + parentId = "el_system", + evidenceIds = betaEvidence, + status = "requiresReview", + reviewReason = "Static host evidence does not prove the deployment boundary.", + }, + }, + relations = Array.Empty(), + }); + } + + private static string[] EvidenceIdsForPrefix( + JsonElement snapshot, + string prefix) => + [ + .. snapshot.GetProperty("evidence") + .EnumerateArray() + .Where(item => + item.TryGetProperty("relativePath", out JsonElement path) + && path.ValueKind == JsonValueKind.String + && path.GetString() is string relativePath + && relativePath.StartsWith(prefix, StringComparison.Ordinal)) + .Select(item => item.GetProperty("id").GetString()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Cast() + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal), + ]; + public static string SnapshotId(ScriptedChatInvocation invocation) => invocation.FindStructuredResult("snapshotId") .GetProperty("snapshotId") @@ -485,6 +557,97 @@ public static IReadOnlyList> AnalysisT return turns; } + public static IReadOnlyList> MultiC3AnalysisTurns( + string summary) => + [ + invocation => + { + EnsureTool(invocation, "inspect_repository"); + return ToolCalls( + Call( + "inspect-1", + "inspect_repository", + new Dictionary + { + ["repositoryPath"] = ".", + })); + }, + invocation => + { + string snapshotId = SnapshotId(invocation); + EnsureTool(invocation, "get_evidence"); + EnsureTool(invocation, "get_snapshot"); + + return ToolCalls( + Call( + "evidence-1", + "get_evidence", + new Dictionary + { + ["snapshotId"] = snapshotId, + ["pageSize"] = 100, + }), + Call( + "files-1", + "get_snapshot", + new Dictionary + { + ["snapshotId"] = snapshotId, + ["section"] = "files", + ["pageSize"] = 100, + }), + Call( + "diagnostics-1", + "get_snapshot", + new Dictionary + { + ["snapshotId"] = snapshotId, + ["section"] = "diagnostics", + ["pageSize"] = 100, + })); + }, + invocation => + { + string snapshotId = invocation.FindStructuredResult("repositoryId") + .GetProperty("snapshotId") + .GetString() + ?? throw new InvalidOperationException("Snapshot ID is unavailable."); + JsonElement snapshot = BuildSnapshot(invocation); + JsonElement c1 = BuildReviewOnlyModel("C1", snapshot, "Multi C3 fixture"); + JsonElement c2 = BuildMultiContainerC2(snapshot); + + EnsureTool(invocation, "generate_likec4"); + return ToolCalls( + Call( + "preview-c1", + "generate_likec4", + new Dictionary + { + ["snapshotId"] = snapshotId, + ["model"] = c1, + ["dryRun"] = true, + ["write"] = false, + ["destinationPath"] = null, + ["c3ContainerId"] = null, + ["c3Containers"] = null, + }), + Call( + "preview-c2", + "generate_likec4", + new Dictionary + { + ["snapshotId"] = snapshotId, + ["model"] = c2, + ["dryRun"] = true, + ["write"] = false, + ["destinationPath"] = null, + ["c3ContainerId"] = null, + ["c3Containers"] = new[] { "el_beta", "el_alpha" }, + })); + }, + _ => Text(summary), + ]; + public static IReadOnlyList> ApprovalTurns() => [ invocation => @@ -541,12 +704,18 @@ private static IReadOnlyList ReplaceValidationTool( Queue outcomes = new( validationResults.SelectMany(outcome => new[] { outcome, outcome })); AIFunction validate = AIFunctionFactory.Create( - (string snapshotId, JsonElement model, string? destinationPath, string? c3ContainerId) => + ( + string snapshotId, + JsonElement model, + string? destinationPath, + string? c3ContainerId, + string[]? c3Containers) => { _ = snapshotId; _ = model; _ = destinationPath; _ = c3ContainerId; + _ = c3Containers; bool isValid = outcomes.Count == 0 || outcomes.Dequeue(); return JsonSerializer.SerializeToElement(new From a18d2d1d35462abcdc7cfe7b498a4e90cff54344 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 22:00:37 -0300 Subject: [PATCH 38/74] fix: preserve singular C3 and MCP result compatibility (#68) --- src/Repo2C4.Agent/AgentMcpSession.cs | 12 +++++++---- src/Repo2C4.Agent/AgentWriteApproval.cs | 2 ++ .../ArchitectureAnalysisWorkflow.cs | 2 ++ src/Repo2C4.Mcp/McpLikeC4Tools.cs | 18 +++++++++++------ src/Repo2C4.Mcp/McpToolContracts.cs | 20 +++++++++++++++---- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/Repo2C4.Agent/AgentMcpSession.cs b/src/Repo2C4.Agent/AgentMcpSession.cs index 8be9aa06..5cad5fce 100644 --- a/src/Repo2C4.Agent/AgentMcpSession.cs +++ b/src/Repo2C4.Agent/AgentMcpSession.cs @@ -176,8 +176,8 @@ public static class Repo2C4McpCapabilities ["get_evidence"] = ["snapshotId"], ["get_snapshot"] = ["snapshotId"], ["get_evidence_report"] = ["snapshotId", "model"], - ["generate_likec4"] = ["snapshotId", "model", "dryRun", "write"], - ["validate_likec4"] = ["snapshotId", "model"], + ["generate_likec4"] = ["snapshotId", "model", "dryRun", "write", "c3Containers"], + ["validate_likec4"] = ["snapshotId", "model", "c3Containers"], }; public static IReadOnlyCollection RequiredToolNames => RequiredNames; @@ -603,7 +603,9 @@ private sealed class PreviewOnlyMcpFunction( safeArguments["destinationPath"] = null; safeArguments["c3ContainerId"] = selection.LegacyContainerId; safeArguments["c3Containers"] = - selection.ContainerIds.Length == 0 ? null : selection.ContainerIds; + selection.LegacyContainerId is not null || selection.ContainerIds.Length == 0 + ? null + : selection.ContainerIds; object? result = await base .InvokeCoreAsync(safeArguments, cancellationToken) @@ -659,7 +661,9 @@ private sealed class ProposalOnlyValidationMcpFunction( safeArguments["destinationPath"] = null; safeArguments["c3ContainerId"] = selection.LegacyContainerId; safeArguments["c3Containers"] = - selection.ContainerIds.Length == 0 ? null : selection.ContainerIds; + selection.LegacyContainerId is not null || selection.ContainerIds.Length == 0 + ? null + : selection.ContainerIds; return base.InvokeCoreAsync(safeArguments, cancellationToken); } diff --git a/src/Repo2C4.Agent/AgentWriteApproval.cs b/src/Repo2C4.Agent/AgentWriteApproval.cs index bca76c7b..739ada7a 100644 --- a/src/Repo2C4.Agent/AgentWriteApproval.cs +++ b/src/Repo2C4.Agent/AgentWriteApproval.cs @@ -185,6 +185,7 @@ public async ValueTask ApplyAsync( ["destinationPath"] = approved.DestinationPath, ["c3ContainerId"] = approved.C3ContainerId, ["c3Containers"] = + approved.C3ContainerId is not null || approved.C3ContainerIds.Count == 0 ? null : approved.C3ContainerIds, @@ -319,6 +320,7 @@ private async ValueTask PreviewAsync( ["destinationPath"] = destinationPath, ["c3ContainerId"] = proposal.C3ContainerId, ["c3Containers"] = + proposal.C3ContainerId is not null || proposal.C3ContainerIds.Count == 0 ? null : proposal.C3ContainerIds, diff --git a/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs b/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs index 0b999934..6a396009 100644 --- a/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs +++ b/src/Repo2C4.Agent/ArchitectureAnalysisWorkflow.cs @@ -740,6 +740,7 @@ public async ValueTask PreviewAsync( ["destinationPath"] = null, ["c3ContainerId"] = proposal.C3ContainerId, ["c3Containers"] = + proposal.C3ContainerId is not null || proposal.C3ContainerIds.Count == 0 ? null : proposal.C3ContainerIds, @@ -811,6 +812,7 @@ public async ValueTask ValidateAsync( ["destinationPath"] = null, ["c3ContainerId"] = proposal.C3ContainerId, ["c3Containers"] = + proposal.C3ContainerId is not null || proposal.C3ContainerIds.Count == 0 ? null : proposal.C3ContainerIds, diff --git a/src/Repo2C4.Mcp/McpLikeC4Tools.cs b/src/Repo2C4.Mcp/McpLikeC4Tools.cs index bed8c4d9..db654084 100644 --- a/src/Repo2C4.Mcp/McpLikeC4Tools.cs +++ b/src/Repo2C4.Mcp/McpLikeC4Tools.cs @@ -150,8 +150,10 @@ or NotSupportedException NormalizeDestinationForResponse(destinationPath), files, changes, - plan?.HasConflicts ?? false, - emission.Views)); + plan?.HasConflicts ?? false) + { + C3Views = emission.Views, + }); } if (plan is null || outputRoot is null) @@ -197,8 +199,10 @@ or NotSupportedException NormalizeDestinationForResponse(destinationPath), files, [.. plan.Changes.Select(ToMcpChange)], - false, - emission.Views)); + false) + { + C3Views = emission.Views, + }); } [Description("Return a metadata-only evidence provenance report for a session-bound v1 model.")] @@ -328,8 +332,10 @@ or NotSupportedException result.IsValid, result.ExitCode, result.TimedOut, - [.. result.Diagnostics], - c3Views)); + [.. result.Diagnostics]) + { + C3Views = c3Views, + }); } finally { diff --git a/src/Repo2C4.Mcp/McpToolContracts.cs b/src/Repo2C4.Mcp/McpToolContracts.cs index 41d39868..f959208e 100644 --- a/src/Repo2C4.Mcp/McpToolContracts.cs +++ b/src/Repo2C4.Mcp/McpToolContracts.cs @@ -92,8 +92,14 @@ public sealed record McpGenerateLikeC4Result( string? DestinationPath, McpLikeC4File[] Files, McpGeneratedFileChange[] Changes, - bool HasConflicts, - McpC3View[] C3Views); + bool HasConflicts) +{ + public McpC3View[] C3Views + { + get; + init; + } = []; +} public sealed record McpValidateLikeC4Result( string SnapshotId, @@ -102,5 +108,11 @@ public sealed record McpValidateLikeC4Result( bool IsValid, int ExitCode, bool TimedOut, - Repo2C4.Core.LikeC4.LikeC4ValidationDiagnostic[] Diagnostics, - McpC3View[] C3Views); + Repo2C4.Core.LikeC4.LikeC4ValidationDiagnostic[] Diagnostics) +{ + public McpC3View[] C3Views + { + get; + init; + } = []; +} From 3d69a3ec07b82233942c0acb647b959090e84378 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 22:01:14 -0300 Subject: [PATCH 39/74] test: exercise Agent multi-C3 through real MCP and HITL (#68) --- .../Repo2C4.Agent.Tests/AgentEndToEndTests.cs | 69 +++++++++++++++++++ tests/Repo2C4.Agent.Tests/AgentHostTests.cs | 25 +++++++ 2 files changed, 94 insertions(+) diff --git a/tests/Repo2C4.Agent.Tests/AgentEndToEndTests.cs b/tests/Repo2C4.Agent.Tests/AgentEndToEndTests.cs index 370feec1..d841d9ed 100644 --- a/tests/Repo2C4.Agent.Tests/AgentEndToEndTests.cs +++ b/tests/Repo2C4.Agent.Tests/AgentEndToEndTests.cs @@ -419,6 +419,75 @@ await DeterministicAgentHarness.CreateAsync( chatClient.AssertExhausted(); } + [Fact] + public async Task MultiC3UsesRealMcpSelectsRelevantSubsetAndCarriesSelectionToHitl() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + await using DeterministicAgentHarness harness = + await DeterministicAgentHarness.CreateAsync( + "agent-multi-c3", + [true], + writeDestination: "generated", + c3ContainerIds: ["el_alpha", "el_beta", "el_unused"], + cancellationToken: cancellationToken); + + const string summary = + """ + Confirmed facts + Alpha and Beta expose evidence-backed HTTP host candidates. + + Requires review + Their deployment and internal component boundaries remain review-required. + + Diagnostics/blockers + el_unused is authorized by the host but unsupported by the C2 evidence and was intentionally omitted. + + Proposal + C1/C2 previews were produced and C3 was requested only for el_alpha and el_beta. + """; + + using ScriptedChatClient analysis = + new([.. DeterministicAgentHarness.MultiC3AnalysisTurns(summary)]); + + ArchitectureWorkflowResult workflow = await harness.RunWorkflowAsync( + analysis, + cancellationToken); + + Assert.Equal(ArchitectureWorkflowStatus.RequiresReview, workflow.Status); + AgentArchitectureProposal c2 = Assert.Single( + harness.Session.InvocationState.SnapshotProposals(), + proposal => proposal.Level == "C2"); + Assert.Null(c2.C3ContainerId); + Assert.Equal(["el_alpha", "el_beta"], c2.C3ContainerIds); + Assert.DoesNotContain("el_unused", c2.C3ContainerIds); + + string structuredResults = JsonSerializer.Serialize( + analysis.Invocations.SelectMany(invocation => invocation.StructuredResults)); + Assert.Contains("c3Views", structuredResults, StringComparison.Ordinal); + Assert.Contains("c3_el_alpha", structuredResults, StringComparison.Ordinal); + Assert.Contains("c3_el_beta", structuredResults, StringComparison.Ordinal); + Assert.DoesNotContain("el_unused", structuredResults, StringComparison.Ordinal); + + DeterministicApprovalPrompt prompt = new(HumanApprovalDecision.Deny); + using ScriptedChatClient approval = CreateApprovalClient(); + + WriteApprovalResult write = await harness.RunWriteAsync( + approval, + prompt, + workflow, + cancellationToken); + + Assert.Equal(WriteApprovalStatus.Denied, write.Status); + AgentWritePlanItem c2Plan = Assert.Single( + prompt.Plan!.Items, + item => item.Level == "C2"); + Assert.Equal(["el_alpha", "el_beta"], c2Plan.C3ContainerIds); + Assert.False(HasGeneratedFiles(harness.RepositoryPath)); + + analysis.AssertExhausted(); + approval.AssertExhausted(); + } + [Fact] public async Task RealLikeC4ValidatorRunsOnlyInOptInIntegrationPass() { diff --git a/tests/Repo2C4.Agent.Tests/AgentHostTests.cs b/tests/Repo2C4.Agent.Tests/AgentHostTests.cs index 0270472e..9d0eee3b 100644 --- a/tests/Repo2C4.Agent.Tests/AgentHostTests.cs +++ b/tests/Repo2C4.Agent.Tests/AgentHostTests.cs @@ -52,6 +52,31 @@ public void IntegrationReportMustBeRepositoryRelative() Assert.Contains("repository-relative", error, StringComparison.Ordinal); } + [Fact] + public void C3AuthorizationSetIsBounded() + { + List args = + [ + "--provider", "ollama", + "--model", "model", + "--goal", "Document architecture", + ]; + + for (int index = 0; index <= AgentHostOptions.MaxC3ContainersPerRun; index++) + { + args.Add("--c3-container"); + args.Add("el_" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + bool valid = AgentHostOptions.TryParse( + [.. args], + out _, + out string? error); + + Assert.False(valid); + Assert.Contains("at most 8", error, StringComparison.Ordinal); + } + [Theory] [InlineData(new[] { "--model", "model" }, "--provider")] [InlineData(new[] { "--provider", "ollama" }, "--model")] From 7561dd14bbd91efc988c755295f8b06ee6dee5e4 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 22:03:23 -0300 Subject: [PATCH 40/74] test: align multi-C3 fixtures with analyzer policy (#68) --- tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs | 4 +++- tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs index da72db3d..25f681f2 100644 --- a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs +++ b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs @@ -8,6 +8,8 @@ namespace Repo2C4.Agent.Tests; public sealed class EvidenceFirstAnalysisTests { + private static readonly string[] RequestedC3WithAttacker = + ["el_beta", "el_attacker", "el_beta"]; [Fact] public void PromptUsesOnlyTheHostAuthorizedIntegrationReportAndPreservesExternalSemantics() { @@ -190,7 +192,7 @@ await generate.InvokeAsync( ["dryRun"] = false, ["write"] = true, ["destinationPath"] = "attacker-selected", - ["c3Containers"] = new[] { "el_beta", "el_attacker", "el_beta" }, + ["c3Containers"] = RequestedC3WithAttacker, }, TestContext.Current.CancellationToken); diff --git a/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs b/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs index aa85bad9..297c5eb7 100644 --- a/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs +++ b/tests/Repo2C4.Agent.Tests/ScriptedAgentHarness.cs @@ -172,6 +172,8 @@ public ValueTask RequestAsync( internal sealed class DeterministicAgentHarness : IAsyncDisposable { + private static readonly string[] MultiC3Selection = ["el_beta", "el_alpha"]; + private readonly TempRepositoryFixture fixture; private readonly IAgentMcpSession session; @@ -642,7 +644,7 @@ public static IReadOnlyList> MultiC3An ["write"] = false, ["destinationPath"] = null, ["c3ContainerId"] = null, - ["c3Containers"] = new[] { "el_beta", "el_alpha" }, + ["c3Containers"] = MultiC3Selection, })); }, _ => Text(summary), From fa2eafc1c1edd4055556753accc93c381ea8038e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 22:09:23 -0300 Subject: [PATCH 41/74] docs: document multi-C3 MCP contract (#68) --- docs/mcp-quickstart.md | 2 +- docs/mcp-quickstart.pt-BR.md | 2 +- docs/mcp.md | 18 +++++++++--------- docs/mcp.pt-BR.md | 18 +++++++++--------- .../EvidenceFirstAnalysisTests.cs | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/mcp-quickstart.md b/docs/mcp-quickstart.md index 2a2bde2d..09118ef2 100644 --- a/docs/mcp-quickstart.md +++ b/docs/mcp-quickstart.md @@ -59,7 +59,7 @@ After the client connects, inspect the authorized root itself: 8. Only after explicit local approval, call `generate_likec4` with `dryRun=false`, `write=true` and the exact repository-relative destination. 9. Call `validate_likec4` again with the written `destinationPath`. -For selective C3, pass the exact ID of an existing C2 container as `c3ContainerId` to both proposal generation and proposal validation. +For selective C3, new clients should pass only the evidence-supported C2 container IDs in `c3Containers` to both proposal generation and proposal validation, for example `["container_api", "container_worker"]`. The collection is bounded to 8 selections and duplicates are deduplicated deterministically. Legacy `c3ContainerId` remains supported for one container. The host selects the AI model. Repo2C4 MCP has no cloud-provider key and does not promote hypotheses to facts. diff --git a/docs/mcp-quickstart.pt-BR.md b/docs/mcp-quickstart.pt-BR.md index 7677c58b..895c4dd9 100644 --- a/docs/mcp-quickstart.pt-BR.md +++ b/docs/mcp-quickstart.pt-BR.md @@ -61,7 +61,7 @@ As evidências importadas usam categorias `external.*`, entram no mesmo snapshot 8. Somente após aprovação local explícita, chame `generate_likec4` com `dryRun=false`, `write=true` e exatamente o destino relativo aprovado. 9. Chame `validate_likec4` novamente com o `destinationPath` gravado. -Para C3 seletivo, passe o ID exato de um container C2 existente em `c3ContainerId` tanto na geração da proposta quanto na validação da proposta. +Para C3 seletivo, clientes novos devem passar somente os IDs de containers C2 sustentados por evidência em `c3Containers` tanto na geração quanto na validação da proposta, por exemplo `["container_api", "container_worker"]`. A coleção é limitada a 8 seleções e duplicatas são deduplicadas deterministicamente. O `c3ContainerId` legado continua suportado para um único container. O host escolhe o modelo de IA. O MCP Repo2C4 não possui chave de provedor cloud e não transforma hipótese em fato. diff --git a/docs/mcp.md b/docs/mcp.md index 225df66c..db3442d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -81,8 +81,8 @@ The normal local server exposes six tools: | `get_evidence` | Page through v1 evidence from a snapshot. | `snapshotId`, optional exact `category`, optional metadata `pathPrefix`, `pageSize`, opaque `cursor`. | Read-only. | | `get_snapshot` | Page file metadata or diagnostics without returning file bodies. | `snapshotId`, `section` = `files` or `diagnostics`, optional `pathPrefix`. | Read-only. | | `get_evidence_report` | Return a bounded provenance/review summary for a session-bound model. | `snapshotId`, complete `ArchitectureModel` v1. | Read-only. | -| `generate_likec4` | Generate deterministic C1/C2 LikeC4 and optional selective C3. | `snapshotId`, complete model, optional `destinationPath`, optional `c3ContainerId`. | Preview by default. Explicit flags are required to write. | -| `validate_likec4` | Validate a proposed model or existing generated workspace through the controlled official LikeC4 CLI adapter. | `snapshotId`, complete model, optional `destinationPath`, optional `c3ContainerId`. | Read-only. | +| `generate_likec4` | Generate deterministic C1/C2 LikeC4 and optional selective multi-container C3. | `snapshotId`, complete model, optional `destinationPath`, preferred optional `c3Containers`, legacy optional `c3ContainerId`. | Preview by default. Explicit flags are required to write. | +| `validate_likec4` | Validate a proposed model or existing generated workspace through the controlled official LikeC4 CLI adapter. | `snapshotId`, complete model, optional `destinationPath`, preferred optional `c3Containers`, legacy optional `c3ContainerId`. | Read-only. | `inspect_remote_repository` is **not** exposed by default. It appears only when the host explicitly starts the server with `--allow-remote-acquisition`. @@ -259,23 +259,23 @@ Unknown files are never deleted. Writes are prepared as a managed set, committed If state changes between preview and apply, the write is rejected with `managed_output_conflict`; preview again instead of forcing the write. -## Selective C3 +## Selective multi-container C3 -C1/C2 are the default. `generate_likec4` and proposal-mode `validate_likec4` accept an optional `c3ContainerId`: +C1/C2 are the default. For new clients, `generate_likec4` and proposal-mode `validate_likec4` accept the typed `c3Containers` collection: ```jsonc { "snapshotId": "", - "model": { "...": "complete C1/C2 ArchitectureModel v1" }, - "c3ContainerId": "container_api" + "model": { "...": "complete C2 ArchitectureModel v1" }, + "c3Containers": ["container_api", "container_worker"] } ``` -The ID must identify an existing C2 container in the supplied model. Only that container is expanded. Other containers do not receive C3 automatically. +At most 8 C3 selections are accepted per call. Repeated IDs are canonicalized deterministically, and the workspace is additionally bounded to 256 components and 512 relations before emission. Every selected ID is validated against the supplied C2 model before managed-output preview or write. Invalid IDs, non-container selections, C1 input, insufficient evidence, schema mismatch or C3 budget violations fail with controlled errors rather than inventing components. -The C3 builder derives bounded component candidates from evidence associated with the selected container's repository paths. Candidate/static signals remain reviewable; unsupported container IDs, insufficient evidence, schema mismatch or C3 limits fail with controlled model errors rather than inventing components. +Only the explicitly requested containers are expanded; the server never selects every C2 container automatically. Generated component boundaries remain evidence-linked and reviewable. Successful proposal generation and proposal validation return a bounded `c3Views` array containing each selected `containerId` and emitted deterministic `viewId`. -Use the same `c3ContainerId` when validating the proposal that was generated with C3. +The legacy singular `c3ContainerId` remains accepted and preserves the prior single-container behavior, including the single `c3` view identifier. New multi-container clients should use `c3Containers`. Use the same selection when validating the proposal that was generated with C3. ## Optional public Git acquisition diff --git a/docs/mcp.pt-BR.md b/docs/mcp.pt-BR.md index d80f072d..ad160636 100644 --- a/docs/mcp.pt-BR.md +++ b/docs/mcp.pt-BR.md @@ -81,8 +81,8 @@ O servidor local normal expõe seis tools: | `get_evidence` | Pagina evidências v1 de um snapshot. | `snapshotId`, `category` exata opcional, `pathPrefix` opcional de metadados, `pageSize`, `cursor` opaco. | Somente leitura. | | `get_snapshot` | Pagina metadados de arquivos ou diagnósticos sem retornar corpos de arquivos. | `snapshotId`, `section` = `files` ou `diagnostics`, `pathPrefix` opcional. | Somente leitura. | | `get_evidence_report` | Devolve resumo limitado de proveniência/revisão para um modelo vinculado à sessão. | `snapshotId`, `ArchitectureModel` v1 completo. | Somente leitura. | -| `generate_likec4` | Gera LikeC4 C1/C2 determinístico e C3 seletivo opcional. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3ContainerId` opcional. | Preview por padrão. Flags explícitas são obrigatórias para escrever. | -| `validate_likec4` | Valida modelo proposto ou workspace gerado existente pelo adaptador controlado da CLI oficial LikeC4. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3ContainerId` opcional. | Somente leitura. | +| `generate_likec4` | Gera LikeC4 C1/C2 determinístico e C3 seletivo multi-container opcional. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3Containers` opcional preferencial, `c3ContainerId` opcional legado. | Preview por padrão. Flags explícitas são obrigatórias para escrever. | +| `validate_likec4` | Valida modelo proposto ou workspace gerado existente pelo adaptador controlado da CLI oficial LikeC4. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3Containers` opcional preferencial, `c3ContainerId` opcional legado. | Somente leitura. | `inspect_remote_repository` **não** é exposta por padrão. Ela aparece somente quando o host inicia explicitamente o servidor com `--allow-remote-acquisition`. @@ -259,23 +259,23 @@ Arquivos desconhecidos nunca são excluídos. As escritas são preparadas como c Se o estado mudar entre preview e apply, a escrita falha com `managed_output_conflict`; faça novo preview em vez de forçar. -## C3 seletivo +## C3 seletivo multi-container -C1/C2 são o padrão. `generate_likec4` e `validate_likec4` em modo de proposta aceitam `c3ContainerId` opcional: +C1/C2 são o padrão. Para clientes novos, `generate_likec4` e `validate_likec4` em modo de proposta aceitam a coleção tipada `c3Containers`: ```jsonc { "snapshotId": "", - "model": { "...": "ArchitectureModel v1 C1/C2 completo" }, - "c3ContainerId": "container_api" + "model": { "...": "ArchitectureModel v1 C2 completo" }, + "c3Containers": ["container_api", "container_worker"] } ``` -O ID deve identificar um container C2 existente no modelo informado. Somente esse container é expandido. Outros containers não recebem C3 automaticamente. +Cada chamada aceita no máximo 8 seleções C3. IDs repetidos são canonicalizados deterministicamente e o workspace também fica limitado a 256 componentes e 512 relações antes da emissão. Todo ID selecionado é validado contra o modelo C2 informado antes do preview ou da escrita gerenciada. ID inválido, seleção que não é container, entrada C1, evidência insuficiente, schema incompatível ou estouro de budget C3 falham de forma controlada em vez de inventar componentes. -O builder C3 deriva candidatos de componentes limitados a partir das evidências associadas aos caminhos do container selecionado. Sinais candidatos/estáticos continuam revisáveis; container inexistente, evidência insuficiente, schema incompatível ou limites C3 falham de forma controlada em vez de inventar componentes. +Somente os containers solicitados explicitamente são expandidos; o servidor nunca seleciona automaticamente todos os containers C2. Fronteiras de componentes continuam ligadas a evidências e revisáveis. Geração e validação de proposta bem-sucedidas retornam um array limitado `c3Views` com o `containerId` selecionado e o `viewId` determinístico emitido. -Use o mesmo `c3ContainerId` ao validar a proposta gerada com C3. +O campo singular legado `c3ContainerId` continua aceito e preserva o comportamento anterior de um único container, incluindo o identificador de view `c3`. Clientes multi-container novos devem usar `c3Containers`. Use a mesma seleção ao validar a proposta gerada com C3. ## Aquisição Git pública opcional diff --git a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs index 25f681f2..654caa5e 100644 --- a/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs +++ b/tests/Repo2C4.Agent.Tests/EvidenceFirstAnalysisTests.cs @@ -169,7 +169,7 @@ await generate.InvokeAsync( Assert.False(generation.Write); Assert.Null(generation.DestinationPath); Assert.Equal("el_web", generation.C3ContainerId); - Assert.Equal(["el_web"], generation.C3ContainerIds); + Assert.Empty(generation.C3ContainerIds); } [Fact] From 57d5f1e8be5e74bed383437dd87910c284d6464d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Fri, 2 Oct 2026 22:10:44 -0300 Subject: [PATCH 42/74] docs: document Agent multi-C3 authorization (#68) --- CHANGELOG.md | 1 + README.md | 4 ++-- docs/agent.md | 12 ++++++++---- docs/agent.pt-BR.md | 12 ++++++++---- examples/agent/README.md | 9 +++++---- examples/agent/README.pt-BR.md | 9 +++++---- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cffc275b..1a1e6963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- Bounded Semantic C3 multi-container support across MCP and Agent: typed `c3Containers` with legacy singular compatibility, deterministic `c3Views` reporting, pre-write ID/budget validation, repeated Agent authorization with evidence-supported subset selection, and exact C3 selection preserved through preview, validation and HITL write approval. - Repeatable CLI `--c3-container` selection backed by one canonical multi-container C3 workspace, deterministic per-container LikeC4 views, duplicate-ID normalization, pre-write validation, and managed-output compatibility while preserving the single-container behavior. - Bounded Semantic C3 internal/external relation composition with explicit wiring+invocation confidence policy, deterministic edge deduplication, weak-cycle/cross-container safeguards, existing C1/C2 peer reuse, and a signal/missing-signal evidence report. - Reviewable Semantic C3 worker/messaging/persistence proposals that merge hosted-service evidence, distinguish supported publish/consume adapters through Integration Evidence, require observed use before promoting repository-shaped persistence, reuse existing C1/C2 external peers, and keep weak/unlinked evidence conservative. diff --git a/README.md b/README.md index 78c3ef07..bfed7f72 100644 --- a/README.md +++ b/README.md @@ -331,9 +331,9 @@ CLI help is written to stdout. MCP help and diagnostics are written **only to st -### Selective C3 +### Selective multi-container C3 -C1/C2 generation remains the default. To derive a reviewable C3 proposal for exactly one existing C2 container, pass `--c3-container ` to the CLI or `c3ContainerId` to `generate_likec4` over MCP. The generated component boundaries are evidence-linked, bounded, and kept under review when repository-static signals cannot prove runtime behavior. Other containers are not expanded automatically. +C1/C2 generation remains the default. The CLI accepts repeated `--c3-container `; MCP clients should use the bounded `c3Containers` collection (with legacy `c3ContainerId` preserved for one container); and the Agent accepts repeated `--c3-container` values as an authorization set from which it must choose only evidence-supported targets. Generated component boundaries are evidence-linked, deterministic and reviewable. Unselected or unsupported containers are never expanded automatically. ### Managed regeneration diff --git a/docs/agent.md b/docs/agent.md index c09046db..077ec615 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -192,9 +192,9 @@ Public terminal statuses are `completed`, `requires_review`, `validation_failed` A validated LikeC4 workspace does **not** prove that the architecture claims are true. Evidence quality and review status remain visible. -## Selective C3 with the Agent +## Selective multi-container C3 with the Agent -C1/C2 remain the default. To request C3, first identify the exact ID of a container that already exists in the reviewed C2 proposal, then authorize only that container for the run: +C1/C2 remain the default. Repeat `--c3-container` to authorize a bounded set of possible C3 targets for the run: ```bash repo2c4-agent \ @@ -202,10 +202,13 @@ repo2c4-agent \ --model YOUR_LOCAL_MODEL \ --repository-root "/absolute/path/to/repository" \ --c3-container "container_api" \ - --goal "Document C1/C2 and, only when supported by evidence, propose C3 for container_api." + --c3-container "container_worker" \ + --goal "Document C1/C2 and propose C3 only for relevant containers supported by evidence." ``` -The ID must be the C2 container's architecture/model ID, not a project name guessed from the repository. The host pins the selected value: repository content and the model cannot switch C3 to another container. If the selected container is absent from the C2 proposal or evidence is insufficient, C3 is omitted rather than invented. +Up to 8 container IDs may be authorized. These values are an allow-list, not an instruction to expand every listed container. The Agent must explicitly request only the subset relevant to the objective and supported by the reviewed C2 evidence. The host intersects that request with the allow-list, so repository content or model output cannot escalate C3 to another target. Ambiguous or unsupported component boundaries remain `requiresReview` or are omitted. + +A single `--c3-container` preserves the prior behavior. For multi-container proposals, the exact selected IDs are carried through dry-run preview, validation, correction attempts and the destination-specific HITL write plan. The approval prompt shows the selected C3 containers before any protected write. ## Human approval and writing @@ -239,6 +242,7 @@ Safe defaults are configurable only inside bounded ranges: | Tool calls | `--max-tool-calls` | 40 | 1–100 | | Workflow iterations | `--max-workflow-iterations` | 3 | 1–3 | | Validation attempts | `--max-validation-attempts` | 2 | 1–3 | +| Authorized C3 containers | repeated `--c3-container` | 0 | 0–8 | | Evidence pages | `--max-evidence-pages` | 20 | 1–50 | | Accumulated response | `--max-response-chars` | 32000 | 1024–100000 | | Accumulated context | `--max-context-chars` | 64000 | 4096–200000 | diff --git a/docs/agent.pt-BR.md b/docs/agent.pt-BR.md index afa3f475..3a7c3659 100644 --- a/docs/agent.pt-BR.md +++ b/docs/agent.pt-BR.md @@ -181,9 +181,9 @@ Os status públicos são `completed`, `requires_review`, `validation_failed`, `c Um workspace LikeC4 válido **não** prova que as afirmações arquiteturais são verdadeiras. Qualidade da evidência e estado de revisão continuam visíveis. -## C3 seletivo com o Agent +## C3 seletivo multi-container com o Agent -C1/C2 continuam sendo o padrão. Para solicitar C3, primeiro identifique o ID exato de um container já existente na proposta C2 revisada e autorize somente esse container na execução: +C1/C2 continuam sendo o padrão. Repita `--c3-container` para autorizar um conjunto limitado de possíveis alvos C3 na execução: ```bash repo2c4-agent \ @@ -191,10 +191,13 @@ repo2c4-agent \ --model SEU_MODELO_LOCAL \ --repository-root "/caminho/absoluto/do/repositorio" \ --c3-container "container_api" \ - --goal "Documente C1/C2 e, somente com evidência suficiente, proponha C3 para container_api." + --c3-container "container_worker" \ + --goal "Documente C1/C2 e proponha C3 somente para containers relevantes sustentados por evidência." ``` -O valor deve ser o ID arquitetural/do modelo do container C2, não um nome de projeto inferido do repositório. O host fixa essa seleção: conteúdo do repositório e o modelo não podem trocar o C3 para outro container. Se o container não existir na proposta C2 ou a evidência for insuficiente, o C3 é omitido em vez de inventado. +Podem ser autorizados no máximo 8 IDs. Esses valores formam uma allow-list; eles não instruem o Agent a expandir todos. O Agent deve solicitar explicitamente apenas o subconjunto relevante ao objetivo e sustentado pelas evidências C2 revisadas. O host intersecta a solicitação com a allow-list, portanto conteúdo do repositório ou saída do modelo não conseguem escalar C3 para outro alvo. Fronteiras ambíguas ou sem suporte permanecem `requiresReview` ou são omitidas. + +Uma única ocorrência de `--c3-container` preserva o comportamento anterior. Em propostas multi-container, os IDs efetivamente selecionados são preservados no dry-run, validação, tentativas de correção e plano HITL específico do destino. O prompt de aprovação mostra os containers C3 selecionados antes de qualquer escrita protegida. ## Aprovação humana e escrita @@ -228,6 +231,7 @@ Defaults seguros são configuráveis somente dentro de faixas limitadas: | Tool calls | `--max-tool-calls` | 40 | 1–100 | | Iterações do workflow | `--max-workflow-iterations` | 3 | 1–3 | | Tentativas de validação | `--max-validation-attempts` | 2 | 1–3 | +| Containers C3 autorizados | `--c3-container` repetido | 0 | 0–8 | | Páginas de evidência | `--max-evidence-pages` | 20 | 1–50 | | Resposta acumulada | `--max-response-chars` | 32000 | 1024–100000 | | Contexto acumulado | `--max-context-chars` | 64000 | 4096–200000 | diff --git a/examples/agent/README.md b/examples/agent/README.md index db5e5244..e1f71f29 100644 --- a/examples/agent/README.md +++ b/examples/agent/README.md @@ -37,9 +37,9 @@ For this fixture, a model must not promote a runtime/deployment boundary to `con No managed `.c4` file is created in this step. A successful run prints `Status: completed` or `Status: requires_review`, followed by the run ID and bounded execution counters. -### Optional selective C3 +### Optional selective multi-container C3 -If a reviewed C2 proposal contains a container with architecture ID `container_api`, authorize only that container: +If a reviewed C2 proposal contains multiple evidence-supported containers, repeat `--c3-container` to authorize only the candidates the Agent may consider: ```bash repo2c4-agent \ @@ -47,10 +47,11 @@ repo2c4-agent \ --model YOUR_LOCAL_MODEL \ --repository-root "$(pwd)/examples/fixtures/library-only" \ --c3-container "container_api" \ - --goal "Produce C1/C2 and propose C3 only for container_api when evidence supports it." + --c3-container "container_worker" \ + --goal "Produce C1/C2 and propose C3 only for relevant, evidence-supported authorized containers." ``` -Do not derive the ID from a project name. It must already identify a C2 container; otherwise C3 is omitted. +The repeated values are an allow-list, not a request to expand every container. The Agent selects only the supported subset, with a maximum of 8 authorized IDs. Do not derive IDs from project names; they must identify reviewed C2 containers. ## 3. Optional protected write diff --git a/examples/agent/README.pt-BR.md b/examples/agent/README.pt-BR.md index c4130f32..04353e35 100644 --- a/examples/agent/README.pt-BR.md +++ b/examples/agent/README.pt-BR.md @@ -37,9 +37,9 @@ Nesta fixture, um modelo não deve promover fronteira de runtime/deployment para Nenhum arquivo `.c4` gerenciado é criado nesta etapa. Uma execução bem-sucedida imprime `Status: completed` ou `Status: requires_review`, seguida pelo run ID e pelos contadores limitados da execução. -### C3 seletivo opcional +### C3 seletivo multi-container opcional -Se uma proposta C2 revisada contiver um container com ID arquitetural `container_api`, autorize somente esse container: +Se uma proposta C2 revisada contiver vários containers sustentados por evidência, repita `--c3-container` para autorizar somente os candidatos que o Agent pode considerar: ```bash repo2c4-agent \ @@ -47,10 +47,11 @@ repo2c4-agent \ --model SEU_MODELO_LOCAL \ --repository-root "$(pwd)/examples/fixtures/library-only" \ --c3-container "container_api" \ - --goal "Produza C1/C2 e proponha C3 somente para container_api quando houver evidência suficiente." + --c3-container "container_worker" \ + --goal "Produza C1/C2 e proponha C3 somente para containers autorizados, relevantes e sustentados por evidência." ``` -Não derive o ID do nome de um projeto. Ele precisa identificar previamente um container C2; caso contrário, o C3 é omitido. +Os valores repetidos formam uma allow-list, não uma ordem para expandir todos. O Agent seleciona somente o subconjunto sustentado, com máximo de 8 IDs autorizados. Não derive IDs do nome de projetos; eles devem identificar containers C2 revisados. ## 3. Escrita protegida opcional From f09b57e8332d0e7b0f80dab770b1ef27f2b589e3 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:09:56 -0300 Subject: [PATCH 43/74] feat: connect semantic C3 to persisted inspection snapshots (#69) --- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 231 ++++++++++++-- .../C3/SemanticC3IntegrationProposer.cs | 243 +++++++++++++++ .../Contracts/ArchitectureC3Validator.cs | 5 + .../Contracts/ArchitectureContracts.cs | 23 +- src/Repo2C4.Core/Contracts/ContractJson.cs | 38 +++ .../Contracts/ContractValidator.cs | 288 ++++++++++++++++++ .../ExternalIntegrationContracts.cs | 1 + .../Inspection/RepositoryFactExtractor.cs | 10 + 8 files changed, 820 insertions(+), 19 deletions(-) create mode 100644 src/Repo2C4.Core/C3/SemanticC3IntegrationProposer.cs diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index 41d9277d..b65c62cb 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -1,12 +1,17 @@ using System.Collections.Immutable; using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; namespace Repo2C4.Core.C3; +public sealed record ArchitectureC3BuildResult( + ArchitectureC3Workspace Workspace, + SemanticC3RelationBuildResult? SemanticResult); + /// Builds bounded, evidence-first C3 proposals for explicitly selected C2 containers. public static class ArchitectureC3Builder { - private static readonly (string Prefix, string Name, string Responsibility)[] Categories = + private static readonly (string Prefix, string Name, string Responsibility)[] LegacyCategories = [ ("dotnet.runtime.http.", "HTTP interface", "Receives HTTP traffic and exposes application entry points."), ("dotnet.integration.", "Integration adapter", "Connects the selected container to an external technology or service."), @@ -55,6 +60,11 @@ public static ArchitectureC3Model Build( } public static ArchitectureC3Workspace BuildMany( + ArchitectureModel baseModel, + IEnumerable selectedContainerIds) => + BuildManyDetailed(baseModel, selectedContainerIds).Workspace; + + public static ArchitectureC3BuildResult BuildManyDetailed( ArchitectureModel baseModel, IEnumerable selectedContainerIds) { @@ -71,10 +81,12 @@ public static ArchitectureC3Workspace BuildMany( string[] requested = [.. selectedContainerIds]; if (requested.Length == 0) { - return new ArchitectureC3Workspace( - ContractSchema.Version, - baseModel, - []); + return new ArchitectureC3BuildResult( + new ArchitectureC3Workspace( + ContractSchema.Version, + baseModel, + []), + null); } if (baseModel.Level != ArchitectureLevel.C2) @@ -136,17 +148,28 @@ .. requested .OrderBy(id => id, StringComparer.Ordinal), ]; - ArchitectureC3Selection[] selections = - [ - .. canonicalIds.Select(id => + List selections = []; + List semanticResults = []; + foreach (string id in canonicalIds) + { + if (baseModel.Snapshot.SemanticC3Facts is null) { - ArchitectureC3Model model = BuildSingle(baseModel, elements[id]); - return new ArchitectureC3Selection( - model.SelectedContainerId, - model.Components, - model.Relations); - }), - ]; + ArchitectureC3Model legacy = BuildLegacySingle(baseModel, elements[id]); + selections.Add(new ArchitectureC3Selection( + legacy.SelectedContainerId, + legacy.Components, + legacy.Relations)); + continue; + } + + SemanticC3RelationBuildResult semantic = BuildSemanticSingle( + baseModel, + id, + baseModel.Snapshot.SemanticC3Facts, + baseModel.Snapshot.ExternalIntegrationEvidence); + semanticResults.Add(semantic); + selections.Add(ToSelection(id, semantic.Proposal)); + } ArchitectureC3Workspace workspace = new( ContractSchema.Version, @@ -160,10 +183,182 @@ .. canonicalIds.Select(id => throw new ContractValidationException(errors); } - return workspace; + SemanticC3RelationBuildResult? combined = semanticResults.Count == 0 + ? null + : CombineSemanticResults(canonicalIds, semanticResults); + + return new ArchitectureC3BuildResult(workspace, combined); + } + + private static SemanticC3RelationBuildResult BuildSemanticSingle( + ArchitectureModel baseModel, + string selectedContainerId, + SemanticC3FactSet factSet, + ExternalIntegrationEvidenceResult? externalEvidence) + { + ExternalIntegrationEvidenceResult integrations = externalEvidence ?? + new ExternalIntegrationEvidenceResult( + ExternalIntegrationReportSchema.MinimumVersion, + null, + null, + false, + false, + [], + []); + + SemanticC3Proposal http = SemanticC3HttpApplicationProposer.Propose( + baseModel, + selectedContainerId, + factSet); + SemanticC3Proposal workers = SemanticC3WorkerIntegrationProposer.Propose( + baseModel, + selectedContainerId, + factSet, + integrations); + SemanticC3Proposal adapters = SemanticC3IntegrationProposer.Propose( + baseModel, + selectedContainerId, + factSet, + integrations); + + SemanticC3Proposal seed = MergeSemanticProposals( + selectedContainerId, + http, + workers, + adapters); + + return SemanticC3RelationBuilder.Build( + baseModel, + seed, + factSet, + new SemanticC3RelationBuildOptions + { + MaxComponents = ArchitectureC3Validator.MaxComponents, + MaxRelations = ArchitectureC3Validator.MaxRelations, + }); + } + + private static SemanticC3Proposal MergeSemanticProposals( + string selectedContainerId, + params SemanticC3Proposal[] proposals) + { + SemanticC3ComponentCandidate[] components = + [ + .. proposals + .SelectMany(proposal => proposal.Components) + .GroupBy(component => component.Id, StringComparer.Ordinal) + .Select(group => + { + SemanticC3ComponentCandidate first = group.First(); + return first with + { + EvidenceIds = + [ + .. group.SelectMany(component => component.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + }; + }) + .OrderBy(component => component.Id, StringComparer.Ordinal), + ]; + + SemanticC3RelationCandidate[] relations = + [ + .. proposals + .SelectMany(proposal => proposal.Relations) + .GroupBy(relation => relation.Id, StringComparer.Ordinal) + .Select(group => + { + SemanticC3RelationCandidate first = group.First(); + return first with + { + EvidenceIds = + [ + .. group.SelectMany(relation => relation.EvidenceIds) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal), + ], + }; + }) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + + return new SemanticC3Proposal( + SemanticC3ContractSchema.Version, + [selectedContainerId], + components, + relations); + } + + private static ArchitectureC3Selection ToSelection( + string selectedContainerId, + SemanticC3Proposal proposal) + { + ArchitectureComponent[] components = + [ + .. proposal.Components + .Select(component => new ArchitectureComponent( + component.Id, + component.ContainerId, + component.Name, + component.Responsibility, + component.EvidenceIds, + component.Status, + component.ReviewReason)) + .OrderBy(component => component.Id, StringComparer.Ordinal), + ]; + + ArchitectureComponentRelation[] relations = + [ + .. proposal.Relations + .Select(relation => new ArchitectureComponentRelation( + relation.Id, + relation.SourceComponentId, + relation.DestinationId, + relation.Description, + relation.EvidenceIds, + relation.Status, + relation.ReviewReason)) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]; + + return new ArchitectureC3Selection( + selectedContainerId, + [.. components], + [.. relations]); + } + + private static SemanticC3RelationBuildResult CombineSemanticResults( + string[] selectedContainerIds, + List results) + { + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [.. selectedContainerIds], + [ + .. results + .SelectMany(result => result.Proposal.Components) + .OrderBy(component => component.Id, StringComparer.Ordinal), + ], + [ + .. results + .SelectMany(result => result.Proposal.Relations) + .OrderBy(relation => relation.Id, StringComparer.Ordinal), + ]); + + return new SemanticC3RelationBuildResult( + proposal, + [ + .. results + .SelectMany(result => result.Diagnostics) + .Distinct() + .OrderBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal), + ]); } - private static ArchitectureC3Model BuildSingle( + private static ArchitectureC3Model BuildLegacySingle( ArchitectureModel baseModel, ArchitectureElement selected) { @@ -186,7 +381,7 @@ .. baseModel.Snapshot.Evidence ]; List components = []; - foreach ((string prefix, string name, string responsibility) in Categories) + foreach ((string prefix, string name, string responsibility) in LegacyCategories) { Evidence[] matching = [ diff --git a/src/Repo2C4.Core/C3/SemanticC3IntegrationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3IntegrationProposer.cs new file mode 100644 index 00000000..c7737878 --- /dev/null +++ b/src/Repo2C4.Core/C3/SemanticC3IntegrationProposer.cs @@ -0,0 +1,243 @@ +using System.Collections.Immutable; +using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; + +namespace Repo2C4.Core.C3; + +/// +/// Proposes integration-adapter components only when normalized external evidence can be +/// correlated to exactly one concrete local type in the finding source file. +/// +public static class SemanticC3IntegrationProposer +{ + public static SemanticC3Proposal Propose( + ArchitectureModel baseModel, + string selectedContainerId, + SemanticC3FactSet factSet, + ExternalIntegrationEvidenceResult externalEvidence) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); + ArgumentNullException.ThrowIfNull(factSet); + ArgumentNullException.ThrowIfNull(externalEvidence); + + ArchitectureElement? selected = baseModel.Elements.SingleOrDefault(element => + element.Id == selectedContainerId && + element.Kind == ArchitectureElementKind.Container); + if (selected is null) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.containerMissing", + "$.selectedContainerIds", + "Selected Semantic C3 container must exist in the C2 base model."), + ]); + } + + HashSet projects = ResolveSelectedProjects(baseModel, selected); + SemanticC3Fact[] typeFacts = + [ + .. factSet.Facts + .Where(fact => + fact.Kind == SemanticC3FactKind.TypeDeclaration && + projects.Contains(fact.ProjectPath) && + !fact.Description.StartsWith("Declares interface", StringComparison.Ordinal)) + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + + List components = []; + List relations = []; + + foreach (ExternalIntegrationEvidence evidence in externalEvidence.Evidence + .Where(item => + projects.Contains(item.ProjectPath) && + item.Kind is ExternalIntegrationKind.Http + or ExternalIntegrationKind.Cache + or ExternalIntegrationKind.Storage && + ExternalIntegrationArchitectureMapper.IsSupported(item)) + .OrderBy(item => item.Id, StringComparer.Ordinal)) + { + SemanticC3Fact[] matches = + [ + .. typeFacts.Where(fact => + fact.ProjectPath == evidence.ProjectPath && + fact.SourcePath == evidence.SourcePath), + ]; + if (matches.Length != 1) + { + continue; + } + + SemanticC3Fact typeFact = matches[0]; + string simpleName = SimpleTypeName(typeFact.SourceSymbol.SymbolId); + string technology = Humanize(evidence.Technology); + string name = evidence.Kind switch + { + ExternalIntegrationKind.Cache => technology + " cache adapter", + ExternalIntegrationKind.Storage => technology + " storage adapter", + _ => Humanize(TrimSuffix(simpleName, "Client", "Adapter")) + " integration adapter", + }; + + SemanticC3ComponentCandidate component = new( + StableIds.ForSemanticC3Component( + selected.Id, + SemanticC3ComponentCategory.IntegrationAdapter, + typeFact.SourceSymbol.Id + "|" + evidence.Id), + selected.Id, + SemanticC3ComponentCategory.IntegrationAdapter, + name, + "Connects the selected container to an evidence-backed external integration.", + [typeFact.Id, evidence.Id], + typeFact.SourceSymbol, + ReviewStatus.RequiresReview, + "The external finding is correlated to one concrete local type, but the C3 adapter boundary remains reviewable."); + components.Add(component); + + ArchitectureRelation? mapped = baseModel.Relations.SingleOrDefault(relation => + relation.EvidenceIds.Contains(evidence.Id, StringComparer.Ordinal) && + (relation.SourceId == selected.Id || relation.DestinationId == selected.Id)); + if (mapped is null) + { + continue; + } + + string destinationId = mapped.SourceId == selected.Id + ? mapped.DestinationId + : mapped.SourceId; + relations.Add(new SemanticC3RelationCandidate( + StableIds.ForRelation( + component.Id, + destinationId, + "semantic-c3|integration|" + evidence.Id), + component.Id, + destinationId, + SemanticC3RelationTargetKind.ArchitectureElement, + mapped.Description, + [typeFact.Id, evidence.Id], + ReviewStatus.RequiresReview, + "The existing C1/C2 external relation is associated with this local integration adapter; C3 runtime flow remains reviewable.")); + } + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [selected.Id], + [.. components.OrderBy(component => component.Id, StringComparer.Ordinal)], + [.. relations.OrderBy(relation => relation.Id, StringComparer.Ordinal)]); + + ImmutableArray errors = SemanticC3Validator.Validate(proposal); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return proposal; + } + + private static HashSet ResolveSelectedProjects( + ArchitectureModel model, + ArchitectureElement selected) + { + Dictionary evidenceById = model.Snapshot.Evidence + .ToDictionary(evidence => evidence.Id, StringComparer.Ordinal); + string[] projects = + [ + .. model.Snapshot.Files + .Select(file => file.RelativePath) + .Where(path => Path.GetExtension(path) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal), + ]; + + HashSet selectedProjects = new(StringComparer.Ordinal); + foreach (string evidenceId in selected.EvidenceIds) + { + if (!evidenceById.TryGetValue(evidenceId, out Evidence? evidence)) + { + continue; + } + + if (Path.GetExtension(evidence.RelativePath) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + { + selectedProjects.Add(evidence.RelativePath); + continue; + } + + string? project = projects + .Where(candidate => IsWithinProject(evidence.RelativePath, candidate)) + .OrderByDescending(ProjectDepth) + .ThenBy(candidate => candidate, StringComparer.Ordinal) + .FirstOrDefault(); + if (project is not null) + { + selectedProjects.Add(project); + } + } + + return selectedProjects; + } + + private static bool IsWithinProject(string sourcePath, string projectPath) + { + string directory = DirectoryPart(projectPath); + return directory.Length == 0 || + sourcePath.StartsWith(directory + "/", StringComparison.Ordinal); + } + + private static int ProjectDepth(string projectPath) => + DirectoryPart(projectPath).Count(character => character == '/') + 1; + + private static string DirectoryPart(string path) + { + int separator = path.LastIndexOf('/'); + return separator < 0 ? string.Empty : path[..separator]; + } + + private static string SimpleTypeName(string symbolId) + { + string value = symbolId.StartsWith("T:", StringComparison.Ordinal) + ? symbolId[2..] + : symbolId; + int separator = value.LastIndexOf('.'); + return separator < 0 ? value : value[(separator + 1)..]; + } + + private static string TrimSuffix(string value, params string[] suffixes) + { + foreach (string suffix in suffixes) + { + if (value.EndsWith(suffix, StringComparison.Ordinal) && + value.Length > suffix.Length) + { + return value[..^suffix.Length]; + } + } + + return value; + } + + private static string Humanize(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "External"; + } + + System.Text.StringBuilder builder = new(); + for (int index = 0; index < value.Length; index++) + { + char current = value[index]; + if (index > 0 && + char.IsUpper(current) && + char.IsLower(value[index - 1])) + { + builder.Append(' '); + } + + builder.Append(current is '-' or '_' ? ' ' : current); + } + + return builder.ToString().Trim(); + } +} diff --git a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs index b11b2e9d..091c21ed 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureC3Validator.cs @@ -75,6 +75,11 @@ public static ImmutableArray Validate(ArchitectureC3Model? model) HashSet evidenceIds = model.BaseModel.Snapshot.Evidence .Select(item => item.Id) .ToHashSet(StringComparer.Ordinal); + if (model.BaseModel.Snapshot.SemanticC3Facts is not null) + { + evidenceIds.UnionWith( + model.BaseModel.Snapshot.SemanticC3Facts.Facts.Select(fact => fact.Id)); + } HashSet componentIds = new(StringComparer.Ordinal); for (int i = 0; i < model.Components.Length; i++) diff --git a/src/Repo2C4.Core/Contracts/ArchitectureContracts.cs b/src/Repo2C4.Core/Contracts/ArchitectureContracts.cs index 6fabd031..a3aee533 100644 --- a/src/Repo2C4.Core/Contracts/ArchitectureContracts.cs +++ b/src/Repo2C4.Core/Contracts/ArchitectureContracts.cs @@ -72,7 +72,28 @@ public sealed record RepositorySnapshot( string RepositoryId, ImmutableArray Files, ImmutableArray Evidence, - ImmutableArray Diagnostics); + ImmutableArray Diagnostics) +{ + /// + /// Optional additive Semantic C3 structural facts captured during repository inspection. + /// Facts contain bounded metadata only and never source bodies or arbitrary literals. + /// + public SemanticC3FactSet? SemanticC3Facts + { + get; + init; + } + + /// + /// Optional normalized external integration findings imported from a compatible inspection report. + /// The persisted form is sanitized and bounded by the external-integration contract. + /// + public Repo2C4.Core.ExternalIntegrations.ExternalIntegrationEvidenceResult? ExternalIntegrationEvidence + { + get; + init; + } +} /// Containment is represented by ParentId and validated against the model's element set. public sealed record ArchitectureElement( diff --git a/src/Repo2C4.Core/Contracts/ContractJson.cs b/src/Repo2C4.Core/Contracts/ContractJson.cs index 27ffe9f2..be481a30 100644 --- a/src/Repo2C4.Core/Contracts/ContractJson.cs +++ b/src/Repo2C4.Core/Contracts/ContractJson.cs @@ -70,6 +70,40 @@ .. snapshot.Diagnostics .ThenBy(diagnostic => diagnostic.RelativePath, StringComparer.Ordinal) .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal), ], + SemanticC3Facts = snapshot.SemanticC3Facts is null + ? null + : snapshot.SemanticC3Facts with + { + Facts = + [ + .. snapshot.SemanticC3Facts.Facts + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ], + Diagnostics = + [ + .. snapshot.SemanticC3Facts.Diagnostics + .OrderBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.RelativePath, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal), + ], + }, + ExternalIntegrationEvidence = snapshot.ExternalIntegrationEvidence is null + ? null + : snapshot.ExternalIntegrationEvidence with + { + Evidence = + [ + .. snapshot.ExternalIntegrationEvidence.Evidence + .OrderBy(evidence => evidence.Id, StringComparer.Ordinal), + ], + Diagnostics = + [ + .. snapshot.ExternalIntegrationEvidence.Diagnostics + .OrderBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Path, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal), + ], + }, }; private static ArchitectureModel CanonicalModel(ArchitectureModel model) => @@ -116,6 +150,10 @@ private static JsonSerializerOptions CreateOptions() options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); return options; } } diff --git a/src/Repo2C4.Core/Contracts/ContractValidator.cs b/src/Repo2C4.Core/Contracts/ContractValidator.cs index 68cf404f..19788018 100644 --- a/src/Repo2C4.Core/Contracts/ContractValidator.cs +++ b/src/Repo2C4.Core/Contracts/ContractValidator.cs @@ -283,6 +283,294 @@ private static void ValidateSnapshotInto(RepositorySnapshot? snapshot, string pa Add(errors, "diagnostic.severity", diagnosticPath + ".severity", "Unknown diagnostic severity."); } } + + ValidateSemanticC3Facts(snapshot.SemanticC3Facts, paths, path, errors); + ValidateExternalIntegrationEvidence( + snapshot.ExternalIntegrationEvidence, + snapshot.Evidence, + paths, + path, + errors); + } + + private static void ValidateSemanticC3Facts( + SemanticC3FactSet? factSet, + HashSet inventoriedPaths, + string snapshotPath, + List errors) + { + if (factSet is null) + { + return; + } + + string path = snapshotPath + ".semanticC3Facts"; + if (factSet.SchemaVersion != SemanticC3ContractSchema.Version) + { + Add(errors, "semanticC3.schema", path + ".schemaVersion", "Unsupported Semantic C3 fact schema."); + } + + if (factSet.Facts.IsDefault || factSet.Diagnostics.IsDefault) + { + Add(errors, "collection.missing", path, "Semantic C3 facts and diagnostics must be initialized."); + return; + } + + if (factSet.Facts.Length > 20_000) + { + Add(errors, "semanticC3.factLimit", path + ".facts", "Semantic C3 fact count exceeds the persisted limit."); + } + + HashSet ids = new(StringComparer.Ordinal); + for (int index = 0; index < factSet.Facts.Length; index++) + { + SemanticC3Fact? fact = factSet.Facts[index]; + string factPath = path + ".facts[" + index + "]"; + if (fact is null) + { + Add(errors, "semanticC3.factMissing", factPath, "Semantic C3 fact must not be null."); + continue; + } + + CheckId(fact.Id, factPath + ".id", errors); + if (IsValidId(fact.Id) && !ids.Add(fact.Id)) + { + Add(errors, "id.duplicate", factPath + ".id", "Semantic C3 fact IDs must be unique."); + } + + CheckPath(fact.ProjectPath, factPath + ".projectPath", errors); + CheckPath(fact.SourcePath, factPath + ".sourcePath", errors); + if (IsNormalizedRelativePath(fact.ProjectPath) && + !inventoriedPaths.Contains(fact.ProjectPath)) + { + Add(errors, "semanticC3.projectMissing", factPath + ".projectPath", "Semantic C3 project must exist in the inventory."); + } + + if (IsNormalizedRelativePath(fact.SourcePath) && + !inventoriedPaths.Contains(fact.SourcePath)) + { + Add(errors, "semanticC3.sourceMissing", factPath + ".sourcePath", "Semantic C3 source must exist in the inventory."); + } + + if (fact.Line is <= 0) + { + Add(errors, "semanticC3.line", factPath + ".line", "Semantic C3 line must be a positive 1-based index."); + } + + if (!Enum.IsDefined(fact.Kind)) + { + Add(errors, "semanticC3.kind", factPath + ".kind", "Unknown Semantic C3 fact kind."); + } + + CheckBoundedMetadataText(fact.Category, factPath + ".category", 256, errors); + CheckBoundedMetadataText(fact.Description, factPath + ".description", 512, errors); + + if (fact.SourceSymbol is null) + { + Add(errors, "semanticC3.symbolMissing", factPath + ".sourceSymbol", "Semantic C3 source symbol is required."); + } + else + { + CheckId(fact.SourceSymbol.Id, factPath + ".sourceSymbol.id", errors); + CheckPath(fact.SourceSymbol.ProjectPath, factPath + ".sourceSymbol.projectPath", errors); + CheckBoundedMetadataText(fact.SourceSymbol.SymbolId, factPath + ".sourceSymbol.symbolId", 1_024, errors); + if (!string.Equals(fact.SourceSymbol.ProjectPath, fact.ProjectPath, StringComparison.Ordinal)) + { + Add(errors, "semanticC3.symbolProject", factPath + ".sourceSymbol.projectPath", "Source symbol project must match the fact project."); + } + + if (IsNormalizedRelativePath(fact.ProjectPath) && + !string.IsNullOrWhiteSpace(fact.SourceSymbol.SymbolId)) + { + string stable = StableIds.ForSemanticC3SourceSymbol( + fact.ProjectPath, + fact.SourceSymbol.SymbolId); + if (!string.Equals(stable, fact.SourceSymbol.Id, StringComparison.Ordinal)) + { + Add(errors, "semanticC3.symbolId", factPath + ".sourceSymbol.id", "Semantic C3 source symbol ID is not stable for its project/symbol identity."); + } + } + } + + if (fact.RelatedSymbolId is not null) + { + CheckBoundedMetadataText(fact.RelatedSymbolId, factPath + ".relatedSymbolId", 1_024, errors); + } + } + + for (int index = 0; index < factSet.Diagnostics.Length; index++) + { + RepositoryDiagnostic? diagnostic = factSet.Diagnostics[index]; + string diagnosticPath = path + ".diagnostics[" + index + "]"; + if (diagnostic is null) + { + Add(errors, "diagnostic.missing", diagnosticPath, "Semantic C3 diagnostic must not be null."); + continue; + } + + CheckBoundedMetadataText(diagnostic.Code, diagnosticPath + ".code", 256, errors); + CheckBoundedMetadataText(diagnostic.Message, diagnosticPath + ".message", 512, errors); + if (diagnostic.RelativePath is not null) + { + CheckPath(diagnostic.RelativePath, diagnosticPath + ".relativePath", errors); + } + + if (!Enum.IsDefined(diagnostic.Severity)) + { + Add(errors, "diagnostic.severity", diagnosticPath + ".severity", "Unknown diagnostic severity."); + } + } + } + + private static void ValidateExternalIntegrationEvidence( + Repo2C4.Core.ExternalIntegrations.ExternalIntegrationEvidenceResult? result, + ImmutableArray repositoryEvidence, + HashSet inventoriedPaths, + string snapshotPath, + List errors) + { + if (result is null) + { + return; + } + + string path = snapshotPath + ".externalIntegrationEvidence"; + if (result.Evidence.IsDefault || result.Diagnostics.IsDefault) + { + Add(errors, "collection.missing", path, "External integration evidence and diagnostics must be initialized."); + return; + } + + if (result.Evidence.Length > 5_000) + { + Add(errors, "external.findings.limit", path + ".evidence", "Persisted external integration evidence exceeds the bounded limit."); + } + + Dictionary evidenceById = repositoryEvidence + .Where(item => item is not null) + .ToDictionary(item => item.Id, StringComparer.Ordinal); + HashSet ids = new(StringComparer.Ordinal); + + for (int index = 0; index < result.Evidence.Length; index++) + { + Repo2C4.Core.ExternalIntegrations.ExternalIntegrationEvidence? evidence = result.Evidence[index]; + string evidencePath = path + ".evidence[" + index + "]"; + if (evidence is null) + { + Add(errors, "external.evidence.missing", evidencePath, "External integration evidence must not be null."); + continue; + } + + CheckId(evidence.Id, evidencePath + ".id", errors); + if (IsValidId(evidence.Id) && !ids.Add(evidence.Id)) + { + Add(errors, "id.duplicate", evidencePath + ".id", "External integration evidence IDs must be unique."); + } + + CheckPath(evidence.ProjectPath, evidencePath + ".projectPath", errors); + CheckPath(evidence.SourcePath, evidencePath + ".sourcePath", errors); + if (IsNormalizedRelativePath(evidence.ProjectPath) && + !inventoriedPaths.Contains(evidence.ProjectPath)) + { + Add(errors, "external.projectMissing", evidencePath + ".projectPath", "External integration project must exist in the inventory."); + } + + if (IsNormalizedRelativePath(evidence.SourcePath) && + !inventoriedPaths.Contains(evidence.SourcePath)) + { + Add(errors, "external.sourceMissing", evidencePath + ".sourcePath", "External integration source must exist in the inventory."); + } + + if (evidence.SourceLine <= 0) + { + Add(errors, "external.line", evidencePath + ".sourceLine", "External integration source line must be positive."); + } + + if (!Enum.IsDefined(evidence.Kind) || + !Enum.IsDefined(evidence.Direction) || + !Enum.IsDefined(evidence.Confidence)) + { + Add(errors, "external.vocabulary", evidencePath, "External integration kind, direction and confidence must be supported enum values."); + } + + CheckBoundedMetadataText(evidence.Category, evidencePath + ".category", 256, errors); + CheckBoundedMetadataText(evidence.Description, evidencePath + ".description", 512, errors); + CheckBoundedMetadataText(evidence.Technology, evidencePath + ".technology", 256, errors); + CheckOptionalBoundedMetadataText(evidence.Target, evidencePath + ".target", 512, errors); + CheckOptionalBoundedMetadataText(evidence.ResourceType, evidencePath + ".resourceType", 256, errors); + CheckOptionalBoundedMetadataText(evidence.ConfigurationKey, evidencePath + ".configurationKey", 512, errors); + CheckOptionalBoundedMetadataText(evidence.Contract, evidencePath + ".contract", 512, errors); + + if (evidence.Signals.IsDefault || evidence.Signals.Length is 0 or > 32) + { + Add(errors, "external.signals", evidencePath + ".signals", "External integration signals must be a non-empty bounded collection."); + } + else + { + for (int signalIndex = 0; signalIndex < evidence.Signals.Length; signalIndex++) + { + CheckBoundedMetadataText( + evidence.Signals[signalIndex], + evidencePath + ".signals[" + signalIndex + "]", + 512, + errors); + } + } + + if (!evidenceById.TryGetValue(evidence.Id, out Evidence? persisted) || + persisted != evidence.ToRepositoryEvidence()) + { + Add(errors, "external.repositoryEvidenceMismatch", evidencePath + ".id", "Persisted external integration evidence must match repository evidence."); + } + } + + for (int index = 0; index < result.Diagnostics.Length; index++) + { + Repo2C4.Core.ExternalIntegrations.ExternalIntegrationDiagnostic? diagnostic = result.Diagnostics[index]; + string diagnosticPath = path + ".diagnostics[" + index + "]"; + if (diagnostic is null) + { + Add(errors, "external.diagnosticMissing", diagnosticPath, "External integration diagnostic must not be null."); + continue; + } + + CheckBoundedMetadataText(diagnostic.Code, diagnosticPath + ".code", 256, errors); + CheckBoundedMetadataText(diagnostic.Path, diagnosticPath + ".path", 512, errors); + CheckBoundedMetadataText(diagnostic.Message, diagnosticPath + ".message", 512, errors); + if (!Enum.IsDefined(diagnostic.Severity)) + { + Add(errors, "diagnostic.severity", diagnosticPath + ".severity", "Unknown diagnostic severity."); + } + } + } + + private static void CheckBoundedMetadataText( + string? value, + string path, + int maxLength, + List errors) + { + if (string.IsNullOrWhiteSpace(value) || + value.Length > maxLength || + value.Any(char.IsControl)) + { + Add(errors, "text.invalid", path, "Metadata text must be nonempty, bounded and free of control characters."); + } + } + + private static void CheckOptionalBoundedMetadataText( + string? value, + string path, + int maxLength, + List errors) + { + if (value is not null && + (string.IsNullOrWhiteSpace(value) || + value.Length > maxLength || + value.Any(char.IsControl))) + { + Add(errors, "text.invalid", path, "Optional metadata text must be bounded and free of control characters."); + } } private static void CheckProvenance( diff --git a/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationContracts.cs b/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationContracts.cs index d5854e99..de659b6a 100644 --- a/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationContracts.cs +++ b/src/Repo2C4.Core/ExternalIntegrations/ExternalIntegrationContracts.cs @@ -137,6 +137,7 @@ .. merged.Diagnostics .ThenBy(item => item.RelativePath, StringComparer.Ordinal) .ThenBy(item => item.Message, StringComparer.Ordinal), ], + ExternalIntegrationEvidence = imported, }; _ = ContractJson.SerializeSnapshot(merged); return new ExternalIntegrationSnapshotImportResult( diff --git a/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs b/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs index 9873928c..07d9c0fd 100644 --- a/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs @@ -118,16 +118,26 @@ public static RepositorySnapshot Extract( "Evidence budget reached; some evidence or remaining files were omitted.")); } + SemanticC3FactSet semanticFacts = SemanticC3FactExtractor.Extract( + new SemanticC3FactExtractionOptions(options) + { + Timeout = TimeSpan.FromSeconds(10), + }, + cancellationToken); + RepositorySnapshot result = snapshot with { Evidence = [.. evidence.Items.OrderBy(item => item.Id, StringComparer.Ordinal)], Diagnostics = [ .. diagnostics + .Concat(semanticFacts.Diagnostics) + .Distinct() .OrderBy(item => item.Code, StringComparer.Ordinal) .ThenBy(item => item.RelativePath, StringComparer.Ordinal) .ThenBy(item => item.Message, StringComparer.Ordinal), ], + SemanticC3Facts = semanticFacts, }; ImmutableArray errors = ContractValidator.ValidateSnapshot(result); if (!errors.IsEmpty) From 14712e2ca925435d315ce6db09d1338e1140ded4 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:11:24 -0300 Subject: [PATCH 44/74] feat: emit semantic C3 through CLI and MCP workflows (#69) --- src/Repo2C4.Cli/CliApplication.cs | 19 +++++- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 28 +++++---- src/Repo2C4.Mcp/McpLikeC4Tools.cs | 63 +++++++++++--------- 3 files changed, 69 insertions(+), 41 deletions(-) diff --git a/src/Repo2C4.Cli/CliApplication.cs b/src/Repo2C4.Cli/CliApplication.cs index 9c140bd2..12819034 100644 --- a/src/Repo2C4.Cli/CliApplication.cs +++ b/src/Repo2C4.Cli/CliApplication.cs @@ -552,10 +552,10 @@ private static async Task RunGenerateAsync( string json = await File.ReadAllTextAsync(fullModelPath, cancellationToken).ConfigureAwait(false); ArchitectureModel model = ContractJson.DeserializeModel(json); - ArchitectureC3Workspace c3Workspace = - ArchitectureC3Builder.BuildMany(model, selectedContainers); + ArchitectureC3BuildResult c3Build = + ArchitectureC3Builder.BuildManyDetailed(model, selectedContainers); IReadOnlyList files = - LikeC4Emitter.EmitWithC3(c3Workspace); + LikeC4Emitter.EmitWithC3(c3Build.Workspace); EvidenceReportResult report = EvidenceReportGenerator.Generate(model); List managedFiles = @@ -564,6 +564,19 @@ private static async Task RunGenerateAsync( new LikeC4GeneratedFile(report.FileName, NormalizeText(report.Content)), ]; + if (c3Build.SemanticResult is not null && + model.Snapshot.SemanticC3Facts is not null) + { + EvidenceReportResult semanticReport = + EvidenceReportGenerator.GenerateSemanticC3( + model, + c3Build.SemanticResult, + model.Snapshot.SemanticC3Facts); + managedFiles.Add(new LikeC4GeneratedFile( + semanticReport.FileName, + NormalizeText(semanticReport.Content))); + } + string outputRoot = Path.GetFullPath(outputPath); if (Directory.Exists(outputRoot) && IsReparsePoint(outputRoot)) { diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index b65c62cb..e71018d1 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -21,6 +21,22 @@ private static readonly (string Prefix, string Name, string Responsibility)[] Le public static ArchitectureC3Model Build( ArchitectureModel baseModel, string selectedContainerId) + { + ArchitectureC3BuildResult result = BuildDetailed( + baseModel, + selectedContainerId); + ArchitectureC3Selection selection = result.Workspace.Selections[0]; + return new ArchitectureC3Model( + result.Workspace.SchemaVersion, + result.Workspace.BaseModel, + selection.SelectedContainerId, + selection.Components, + selection.Relations); + } + + public static ArchitectureC3BuildResult BuildDetailed( + ArchitectureModel baseModel, + string selectedContainerId) { ArgumentNullException.ThrowIfNull(baseModel); ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); @@ -46,17 +62,7 @@ public static ArchitectureC3Model Build( ]); } - ArchitectureC3Workspace workspace = BuildMany( - baseModel, - [selectedContainerId]); - - ArchitectureC3Selection selection = workspace.Selections[0]; - return new ArchitectureC3Model( - workspace.SchemaVersion, - workspace.BaseModel, - selection.SelectedContainerId, - selection.Components, - selection.Relations); + return BuildManyDetailed(baseModel, [selectedContainerId]); } public static ArchitectureC3Workspace BuildMany( diff --git a/src/Repo2C4.Mcp/McpLikeC4Tools.cs b/src/Repo2C4.Mcp/McpLikeC4Tools.cs index db654084..b5bb507a 100644 --- a/src/Repo2C4.Mcp/McpLikeC4Tools.cs +++ b/src/Repo2C4.Mcp/McpLikeC4Tools.cs @@ -82,6 +82,7 @@ public async Task GenerateLikeC4( { cancellationToken.ThrowIfCancellationRequested(); McpSnapshotStore.SnapshotEntry entry = ValidateModelBinding(snapshotId, model); + ArchitectureModel boundModel = model with { Snapshot = entry.Snapshot }; if (write && dryRun) { @@ -101,7 +102,7 @@ public async Task GenerateLikeC4( "destination_required: writing requires an explicit repository-relative destinationPath."); } - McpC3Emission emission = EmitForSelection(model, c3ContainerId, c3Containers); + McpC3Emission emission = EmitForSelection(boundModel, c3ContainerId, c3Containers); IReadOnlyList generated = emission.Files; McpLikeC4File[] files = [ @@ -266,6 +267,7 @@ public async Task ValidateLikeC4( { cancellationToken.ThrowIfCancellationRequested(); McpSnapshotStore.SnapshotEntry entry = ValidateModelBinding(snapshotId, model); + ArchitectureModel boundModel = model with { Snapshot = entry.Snapshot }; string workspace; string workspaceLabel; @@ -274,7 +276,7 @@ public async Task ValidateLikeC4( if (string.IsNullOrWhiteSpace(destinationPath)) { - McpC3Emission emission = EmitForSelection(model, c3ContainerId, c3Containers); + McpC3Emission emission = EmitForSelection(boundModel, c3ContainerId, c3Containers); IReadOnlyList generated = emission.Files; c3Views = emission.Views; temporaryWorkspace = Directory.CreateTempSubdirectory("repo2c4-mcp-likec4-").FullName; @@ -361,35 +363,35 @@ private static McpC3Emission EmitForSelection( []); } - ArchitectureC3Workspace workspace; - IReadOnlyList files; + ArchitectureC3BuildResult build = + (c3Containers is null || c3Containers.Count == 0) && + !string.IsNullOrWhiteSpace(c3ContainerId) + ? ArchitectureC3Builder.BuildDetailed(model, c3ContainerId) + : ArchitectureC3Builder.BuildManyDetailed(model, selected); - if ((c3Containers is null || c3Containers.Count == 0) && - !string.IsNullOrWhiteSpace(c3ContainerId)) - { - ArchitectureC3Model c3 = ArchitectureC3Builder.Build(model, c3ContainerId); - workspace = new ArchitectureC3Workspace( - c3.SchemaVersion, - model, - [ - new ArchitectureC3Selection( - c3.SelectedContainerId, - c3.Components, - c3.Relations), - ]); - EnsureC3Budget(workspace); - files = LikeC4Emitter.EmitWithC3(model, c3); - } - else + EnsureC3Budget(build.Workspace); + + List files = + [ + .. LikeC4Emitter.EmitWithC3(build.Workspace), + ]; + + if (build.SemanticResult is not null && + model.Snapshot.SemanticC3Facts is not null) { - workspace = ArchitectureC3Builder.BuildMany(model, selected); - EnsureC3Budget(workspace); - files = LikeC4Emitter.EmitWithC3(workspace); + EvidenceReportResult semanticReport = + EvidenceReportGenerator.GenerateSemanticC3( + model, + build.SemanticResult, + model.Snapshot.SemanticC3Facts); + files.Add(new LikeC4GeneratedFile( + semanticReport.FileName, + semanticReport.Content)); } McpC3View[] views = [ - .. LikeC4Emitter.DescribeC3Views(workspace) + .. LikeC4Emitter.DescribeC3Views(build.Workspace) .Select(view => new McpC3View(view.ContainerId, view.ViewId)), ]; return new McpC3Emission(files, views); @@ -464,8 +466,8 @@ private McpSnapshotStore.SnapshotEntry ValidateModelBinding( string suppliedSnapshot; try { - expectedSnapshot = ContractJson.SerializeSnapshot(entry.Snapshot); - suppliedSnapshot = ContractJson.SerializeSnapshot(model.Snapshot); + expectedSnapshot = ContractJson.SerializeSnapshot(PublicSnapshot(entry.Snapshot)); + suppliedSnapshot = ContractJson.SerializeSnapshot(PublicSnapshot(model.Snapshot)); } catch (ContractValidationException exception) { @@ -485,6 +487,13 @@ private McpSnapshotStore.SnapshotEntry ValidateModelBinding( return entry; } + private static RepositorySnapshot PublicSnapshot(RepositorySnapshot snapshot) => + snapshot with + { + SemanticC3Facts = null, + ExternalIntegrationEvidence = null, + }; + private static void EnsureReviewBoundaries( ArchitectureModel model, RepositorySnapshot snapshot) From 22b307e9c06f48f252958ee359e3986e42e176e6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:12:00 -0300 Subject: [PATCH 45/74] fix: materialize semantic C3 merge collections (#69) --- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index e71018d1..50b73128 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -293,8 +293,8 @@ .. group.SelectMany(relation => relation.EvidenceIds) return new SemanticC3Proposal( SemanticC3ContractSchema.Version, [selectedContainerId], - components, - relations); + [.. components], + [.. relations]); } private static ArchitectureC3Selection ToSelection( From 627afce7be36c2b19394e32df12c71ea3d8c6447 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:13:47 -0300 Subject: [PATCH 46/74] style: format canonical MCP model binding (#69) --- src/Repo2C4.Mcp/McpLikeC4Tools.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Mcp/McpLikeC4Tools.cs b/src/Repo2C4.Mcp/McpLikeC4Tools.cs index b5bb507a..25fc7c5c 100644 --- a/src/Repo2C4.Mcp/McpLikeC4Tools.cs +++ b/src/Repo2C4.Mcp/McpLikeC4Tools.cs @@ -82,7 +82,10 @@ public async Task GenerateLikeC4( { cancellationToken.ThrowIfCancellationRequested(); McpSnapshotStore.SnapshotEntry entry = ValidateModelBinding(snapshotId, model); - ArchitectureModel boundModel = model with { Snapshot = entry.Snapshot }; + ArchitectureModel boundModel = model with + { + Snapshot = entry.Snapshot, + }; if (write && dryRun) { @@ -267,7 +270,10 @@ public async Task ValidateLikeC4( { cancellationToken.ThrowIfCancellationRequested(); McpSnapshotStore.SnapshotEntry entry = ValidateModelBinding(snapshotId, model); - ArchitectureModel boundModel = model with { Snapshot = entry.Snapshot }; + ArchitectureModel boundModel = model with + { + Snapshot = entry.Snapshot, + }; string workspace; string workspaceLabel; From bb025425a537062e429095427715d88ae5b69f7d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:18:49 -0300 Subject: [PATCH 47/74] feat: propose consumer application processors semantically (#69) --- src/Repo2C4.Core/C3/ArchitectureC3Builder.cs | 6 + ...SemanticC3BackgroundApplicationProposer.cs | 480 ++++++++++++++++++ .../C3/SemanticC3RelationBuilder.cs | 43 +- 3 files changed, 518 insertions(+), 11 deletions(-) create mode 100644 src/Repo2C4.Core/C3/SemanticC3BackgroundApplicationProposer.cs diff --git a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs index 50b73128..0e6a6047 100644 --- a/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs +++ b/src/Repo2C4.Core/C3/ArchitectureC3Builder.cs @@ -221,6 +221,11 @@ private static SemanticC3RelationBuildResult BuildSemanticSingle( selectedContainerId, factSet, integrations); + SemanticC3Proposal backgroundApplication = + SemanticC3BackgroundApplicationProposer.Propose( + baseModel, + selectedContainerId, + factSet); SemanticC3Proposal adapters = SemanticC3IntegrationProposer.Propose( baseModel, selectedContainerId, @@ -231,6 +236,7 @@ private static SemanticC3RelationBuildResult BuildSemanticSingle( selectedContainerId, http, workers, + backgroundApplication, adapters); return SemanticC3RelationBuilder.Build( diff --git a/src/Repo2C4.Core/C3/SemanticC3BackgroundApplicationProposer.cs b/src/Repo2C4.Core/C3/SemanticC3BackgroundApplicationProposer.cs new file mode 100644 index 00000000..f1a94d93 --- /dev/null +++ b/src/Repo2C4.Core/C3/SemanticC3BackgroundApplicationProposer.cs @@ -0,0 +1,480 @@ +using System.Collections.Immutable; +using System.Text; +using Repo2C4.Core.Contracts; + +namespace Repo2C4.Core.C3; + +/// +/// Proposes an application/processor component only when a messaging-consumer boundary +/// structurally depends on or invokes one concrete non-infrastructure type. +/// +public static class SemanticC3BackgroundApplicationProposer +{ + private static readonly string[] InfrastructureSuffixes = + [ + "Adapter", + "Client", + "Consumer", + "Context", + "DbContext", + "Producer", + "Publisher", + "Repository", + "Subscriber", + "Worker", + ]; + + public static SemanticC3Proposal Propose( + ArchitectureModel baseModel, + string selectedContainerId, + SemanticC3FactSet factSet) + { + ArgumentNullException.ThrowIfNull(baseModel); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedContainerId); + ArgumentNullException.ThrowIfNull(factSet); + + ArchitectureElement? selected = baseModel.Elements.SingleOrDefault(element => + element.Id == selectedContainerId && + element.Kind == ArchitectureElementKind.Container); + if (selected is null) + { + throw new ContractValidationException( + [ + new ContractError( + "semanticC3.containerMissing", + "$.selectedContainerIds", + "Selected Semantic C3 container must exist in the C2 base model."), + ]); + } + + HashSet selectedProjects = ResolveSelectedProjects(baseModel, selected); + SemanticC3Fact[] facts = + [ + .. factSet.Facts + .Where(fact => selectedProjects.Contains(fact.ProjectPath)) + .OrderBy(fact => fact.Id, StringComparer.Ordinal), + ]; + SemanticC3Fact[] typeFacts = + [ + .. facts.Where(fact => fact.Kind == SemanticC3FactKind.TypeDeclaration), + ]; + + HashSet consumerTypeIds = + [ + .. facts + .Where(fact => + fact.Kind == SemanticC3FactKind.MessagingCandidate && + ConsumerRole(fact.SourceSymbol.SymbolId)) + .Select(fact => fact.SourceSymbol.SymbolId), + ]; + + DiRegistration[] registrations = + [ + .. facts + .Where(fact => fact.Kind == SemanticC3FactKind.DependencyInjectionRegistration) + .Select(ParseRegistration) + .Where(registration => registration is not null) + .Select(registration => registration!) + .OrderBy(registration => registration.Fact.Id, StringComparer.Ordinal), + ]; + + Dictionary candidates = new(StringComparer.Ordinal); + + foreach (SemanticC3Fact injection in facts.Where(fact => + fact.Kind == SemanticC3FactKind.ConstructorInjection && + consumerTypeIds.Contains(DeclaringTypeId(fact.SourceSymbol.SymbolId) ?? string.Empty))) + { + string? dependency = RelatedTypeName(injection.RelatedSymbolId); + if (dependency is null) + { + continue; + } + + SemanticC3Fact? typeFact = ResolveDependencyType( + dependency, + registrations, + typeFacts, + out SemanticC3Fact? registrationFact); + AddCandidate( + candidates, + typeFact, + registrationFact is null ? [injection] : [injection, registrationFact], + facts, + selected.Id); + } + + foreach (SemanticC3Fact invocation in facts.Where(fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation && + consumerTypeIds.Contains(DeclaringTypeId(fact.SourceSymbol.SymbolId) ?? string.Empty))) + { + string? targetType = RelatedMethodTypeName(invocation.RelatedSymbolId); + if (targetType is null) + { + continue; + } + + SemanticC3Fact? typeFact = ResolveConcreteType(typeFacts, targetType); + AddCandidate( + candidates, + typeFact, + [invocation], + facts, + selected.Id); + } + + SemanticC3Proposal proposal = new( + SemanticC3ContractSchema.Version, + [selected.Id], + [ + .. candidates.Values + .Select(CreateCandidate) + .OrderBy(component => component.Id, StringComparer.Ordinal), + ], + []); + + ImmutableArray errors = SemanticC3Validator.Validate(proposal); + if (!errors.IsEmpty) + { + throw new ContractValidationException(errors); + } + + return proposal; + } + + private static void AddCandidate( + Dictionary candidates, + SemanticC3Fact? typeFact, + IEnumerable signals, + SemanticC3Fact[] allFacts, + string containerId) + { + if (typeFact is null || + typeFact.Description.StartsWith("Declares interface", StringComparison.Ordinal) || + IsInfrastructure(typeFact, allFacts) || + !HasApplicationMethod(typeFact, allFacts)) + { + return; + } + + if (!candidates.TryGetValue(typeFact.SourceSymbol.Id, out ApplicationAccumulator? candidate)) + { + candidate = new ApplicationAccumulator(containerId, typeFact); + candidates.Add(typeFact.SourceSymbol.Id, candidate); + } + + candidate.EvidenceIds.Add(typeFact.Id); + foreach (SemanticC3Fact signal in signals) + { + candidate.EvidenceIds.Add(signal.Id); + } + } + + private static SemanticC3ComponentCandidate CreateCandidate( + ApplicationAccumulator candidate) + { + string typeName = SimpleTypeName(candidate.TypeFact.SourceSymbol.SymbolId); + string baseName = TrimSuffix(typeName, "Processor", "Handler", "Service", "UseCase"); + string name = Humanize(baseName); + if (typeName.EndsWith("Processor", StringComparison.Ordinal)) + { + name += " processor"; + } + else if (typeName.EndsWith("Handler", StringComparison.Ordinal)) + { + name += " handler"; + } + else + { + name += " application service"; + } + + return new SemanticC3ComponentCandidate( + StableIds.ForSemanticC3Component( + candidate.ContainerId, + SemanticC3ComponentCategory.ApplicationService, + candidate.TypeFact.SourceSymbol.Id), + candidate.ContainerId, + SemanticC3ComponentCategory.ApplicationService, + name.Trim(), + "Processes a transport-neutral application responsibility reached from a messaging consumer boundary.", + [.. candidate.EvidenceIds.OrderBy(id => id, StringComparer.Ordinal)], + candidate.TypeFact.SourceSymbol, + ReviewStatus.RequiresReview, + "Consumer wiring or direct collaboration supports this application responsibility, but its C3 boundary remains reviewable."); + } + + private static bool IsInfrastructure( + SemanticC3Fact typeFact, + SemanticC3Fact[] facts) + { + string name = SimpleTypeName(typeFact.SourceSymbol.SymbolId); + if (InfrastructureSuffixes.Any(suffix => + name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return facts.Any(fact => + fact.SourceSymbol.Id == typeFact.SourceSymbol.Id && + fact.Kind is SemanticC3FactKind.PersistenceCandidate + or SemanticC3FactKind.MessagingCandidate + or SemanticC3FactKind.HostedService); + } + + private static bool HasApplicationMethod( + SemanticC3Fact typeFact, + SemanticC3Fact[] facts) => + facts.Any(fact => + fact.ProjectPath == typeFact.ProjectPath && + fact.Kind == SemanticC3FactKind.MethodDeclaration && + DeclaringTypeId(fact.SourceSymbol.SymbolId) == typeFact.SourceSymbol.SymbolId); + + private static SemanticC3Fact? ResolveDependencyType( + string dependency, + DiRegistration[] registrations, + SemanticC3Fact[] typeFacts, + out SemanticC3Fact? registrationFact) + { + registrationFact = null; + SemanticC3Fact? direct = ResolveConcreteType(typeFacts, dependency); + if (direct is not null) + { + return direct; + } + + string simple = SimpleTypeName(dependency); + DiRegistration[] matches = + [ + .. registrations.Where(registration => + SimpleTypeName(registration.ServiceType) + .Equals(simple, StringComparison.OrdinalIgnoreCase)), + ]; + if (matches.Length != 1) + { + return null; + } + + registrationFact = matches[0].Fact; + return ResolveConcreteType(typeFacts, matches[0].ImplementationType); + } + + private static DiRegistration? ParseRegistration(SemanticC3Fact fact) + { + string? related = fact.RelatedSymbolId; + if (related is null || !related.StartsWith("DI:", StringComparison.Ordinal)) + { + return null; + } + + int lifetime = related.IndexOf(':', 3); + int arrow = related.IndexOf("->", StringComparison.Ordinal); + int ordinal = related.LastIndexOf(':'); + if (lifetime < 0 || arrow <= lifetime || ordinal <= arrow + 2) + { + return null; + } + + string service = related[(lifetime + 1)..arrow]; + string implementation = related[(arrow + 2)..ordinal]; + return new DiRegistration(service, implementation, fact); + } + + private static SemanticC3Fact? ResolveConcreteType( + IEnumerable typeFacts, + string typeName) + { + string normalized = NormalizeTypeName(typeName); + string simple = SimpleTypeName(normalized); + SemanticC3Fact[] candidates = + [ + .. typeFacts.Where(fact => + !fact.Description.StartsWith("Declares interface", StringComparison.Ordinal) && + (fact.SourceSymbol.SymbolId == "T:" + normalized || + SimpleTypeName(fact.SourceSymbol.SymbolId) + .Equals(simple, StringComparison.OrdinalIgnoreCase))), + ]; + + SemanticC3Fact[] exact = + [ + .. candidates.Where(fact => fact.SourceSymbol.SymbolId == "T:" + normalized), + ]; + return exact.Length == 1 + ? exact[0] + : candidates.Length == 1 + ? candidates[0] + : null; + } + + private static bool ConsumerRole(string symbolId) + { + string name = SimpleTypeName(symbolId); + bool consumer = + name.Contains("Consumer", StringComparison.OrdinalIgnoreCase) || + name.Contains("Subscriber", StringComparison.OrdinalIgnoreCase); + bool publisher = + name.Contains("Publisher", StringComparison.OrdinalIgnoreCase) || + name.Contains("Producer", StringComparison.OrdinalIgnoreCase); + return consumer && !publisher; + } + + private static string? RelatedTypeName(string? related) + { + if (related is null || !related.StartsWith("T:", StringComparison.Ordinal)) + { + return null; + } + + int ordinal = related.LastIndexOf(':'); + return ordinal <= 2 ? null : related[2..ordinal]; + } + + private static string? RelatedMethodTypeName(string? related) + { + if (related is null || !related.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + string value = related[2..]; + int method = value.LastIndexOf('.'); + return method <= 0 ? null : value[..method]; + } + + private static string? DeclaringTypeId(string methodSymbolId) + { + if (!methodSymbolId.StartsWith("M:", StringComparison.Ordinal)) + { + return null; + } + + int parameters = methodSymbolId.IndexOf('('); + string head = parameters >= 0 ? methodSymbolId[..parameters] : methodSymbolId; + int separator = head.LastIndexOf('.'); + return separator <= 2 ? null : "T:" + head[2..separator]; + } + + private static HashSet ResolveSelectedProjects( + ArchitectureModel model, + ArchitectureElement selected) + { + Dictionary evidenceById = model.Snapshot.Evidence + .ToDictionary(evidence => evidence.Id, StringComparer.Ordinal); + string[] projectPaths = + [ + .. model.Snapshot.Files + .Select(file => file.RelativePath) + .Where(path => Path.GetExtension(path) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal), + ]; + + HashSet selectedProjects = new(StringComparer.Ordinal); + foreach (string evidenceId in selected.EvidenceIds) + { + if (!evidenceById.TryGetValue(evidenceId, out Evidence? evidence)) + { + continue; + } + + if (Path.GetExtension(evidence.RelativePath) + .Equals(".csproj", StringComparison.OrdinalIgnoreCase)) + { + selectedProjects.Add(evidence.RelativePath); + continue; + } + + string? nearest = projectPaths + .Where(project => IsWithinProject(evidence.RelativePath, project)) + .OrderByDescending(ProjectDepth) + .ThenBy(project => project, StringComparer.Ordinal) + .FirstOrDefault(); + if (nearest is not null) + { + selectedProjects.Add(nearest); + } + } + + return selectedProjects; + } + + private static bool IsWithinProject(string sourcePath, string projectPath) + { + string directory = DirectoryPart(projectPath); + return directory.Length == 0 || + sourcePath.StartsWith(directory + "/", StringComparison.Ordinal); + } + + private static int ProjectDepth(string projectPath) => + DirectoryPart(projectPath).Count(character => character == '/') + 1; + + private static string DirectoryPart(string path) + { + int separator = path.LastIndexOf('/'); + return separator < 0 ? string.Empty : path[..separator]; + } + + private static string NormalizeTypeName(string value) + { + string normalized = value.Trim().TrimEnd('?'); + int generic = normalized.IndexOf('<'); + return generic < 0 ? normalized : normalized[..generic]; + } + + private static string SimpleTypeName(string value) + { + string normalized = value.StartsWith("T:", StringComparison.Ordinal) + ? value[2..] + : NormalizeTypeName(value); + int separator = normalized.LastIndexOf('.'); + return separator < 0 ? normalized : normalized[(separator + 1)..]; + } + + private static string TrimSuffix(string value, params string[] suffixes) + { + foreach (string suffix in suffixes) + { + if (value.EndsWith(suffix, StringComparison.Ordinal) && + value.Length > suffix.Length) + { + return value[..^suffix.Length]; + } + } + + return value; + } + + private static string Humanize(string value) + { + StringBuilder builder = new(); + for (int index = 0; index < value.Length; index++) + { + char current = value[index]; + if (index > 0 && + char.IsUpper(current) && + char.IsLower(value[index - 1])) + { + builder.Append(' '); + } + + builder.Append(current); + } + + return builder.ToString(); + } + + private sealed record DiRegistration( + string ServiceType, + string ImplementationType, + SemanticC3Fact Fact); + + private sealed class ApplicationAccumulator( + string containerId, + SemanticC3Fact typeFact) + { + public string ContainerId { get; } = containerId; + + public SemanticC3Fact TypeFact { get; } = typeFact; + + public HashSet EvidenceIds { get; } = new(StringComparer.Ordinal); + } +} diff --git a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs index d91ea5c4..0646351d 100644 --- a/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs +++ b/src/Repo2C4.Core/C3/SemanticC3RelationBuilder.cs @@ -100,14 +100,17 @@ .. facts break; case SemanticC3FactKind.ConstructorInjection: + DependencyResolution constructorDestination = ResolveDependency( + fact.RelatedSymbolId, + componentIndex, + registrations, + interfaces); AddWiringSignal( fact, - componentIndex.FindByMethodOwner(fact.SourceSymbol), - ResolveDependency( - fact.RelatedSymbolId, - componentIndex, - registrations, - interfaces), + componentIndex.FindByMethodOwner( + fact.SourceSymbol, + constructorDestination.Component?.Category), + constructorDestination, signals); break; @@ -143,14 +146,16 @@ .. facts break; case SemanticC3FactKind.SymbolInvocation: + SemanticC3ComponentCandidate? invocationDestination = + ResolveInvocationDestination(fact.RelatedSymbolId, componentIndex); SemanticC3ComponentCandidate? invocationSource = handlerIndex.MethodOwners.TryGetValue( fact.SourceSymbol.Id, out HandlerOwner? invocationHandler) ? invocationHandler.Component - : componentIndex.FindByMethodOwner(fact.SourceSymbol); - SemanticC3ComponentCandidate? invocationDestination = - ResolveInvocationDestination(fact.RelatedSymbolId, componentIndex); + : componentIndex.FindByMethodOwner( + fact.SourceSymbol, + invocationDestination?.Category); AddInvocationSignal( fact, invocationSource, @@ -1024,7 +1029,8 @@ .. _components.Where(component => } public SemanticC3ComponentCandidate? FindByMethodOwner( - SemanticC3SourceSymbolIdentity methodSymbol) + SemanticC3SourceSymbolIdentity methodSymbol, + SemanticC3ComponentCategory? destinationCategory = null) { string? declaringType = DeclaringTypeId(methodSymbol.SymbolId); if (declaringType is null) @@ -1038,7 +1044,22 @@ .. _components.Where(component => component.SourceSymbol?.ProjectPath == methodSymbol.ProjectPath && component.SourceSymbol.SymbolId == declaringType), ]; - return matches.Length == 1 ? matches[0] : null; + if (matches.Length == 1) + { + return matches[0]; + } + + if (destinationCategory is null) + { + return null; + } + + SemanticC3ComponentCandidate[] supported = + [ + .. matches.Where(component => + IsSupportedPair(component.Category, destinationCategory.Value)), + ]; + return supported.Length == 1 ? supported[0] : null; } public SemanticC3ComponentCandidate? FindByTypeName(string typeName) From ca36ffda197fc1532131e05e2b693077d25bb5c1 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:24:54 -0300 Subject: [PATCH 48/74] test: add ingestion semantic C3 dogfood sources (#69) --- .../fixtures/semantic-c3-dogfood/README.md | 18 ++++++++++++++++++ .../src/Ingestion.Api/Ingestion.Api.csproj | 6 ++++++ .../src/Ingestion.Api/Program.cs | 7 +++++++ .../Ingestion.Api/Values/IngestValueHandler.cs | 14 ++++++++++++++ .../Values/RedisIdempotencyStore.cs | 8 ++++++++ .../src/Ingestion.Api/Values/TelemetryNames.cs | 6 ++++++ .../Ingestion.Persistence.csproj | 3 +++ .../IngestionDbContext.cs | 11 +++++++++++ 8 files changed, 73 insertions(+) create mode 100644 examples/fixtures/semantic-c3-dogfood/README.md create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Ingestion.Api.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Program.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/IngestValueHandler.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/RedisIdempotencyStore.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/TelemetryNames.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/Ingestion.Persistence.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/IngestionDbContext.cs diff --git a/examples/fixtures/semantic-c3-dogfood/README.md b/examples/fixtures/semantic-c3-dogfood/README.md new file mode 100644 index 00000000..0eba577e --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/README.md @@ -0,0 +1,18 @@ +# Semantic C3 dogfood fixture + +Reduced static fixture derived from `rodri-oliveira-dev/dotnet-observability-lab` main commit +`66b1dc7c789c96c521d08d7012155cf309f3d3f2`. + +It preserves the architectural responsibilities used by the Phase 9 golden tests: + +- Ingestion.Api: HTTP endpoint, ingestion use case, PostgreSQL persistence and Redis adapter. +- Ingestion.Outbox.Worker: hosted poller, RabbitMQ publisher and ingestion persistence. +- Consolidation.Api: HTTP read endpoint, query component and consolidation persistence. +- Consolidation.Worker: RabbitMQ consumer, application processor and consolidation persistence. + +The fixture is intentionally smaller than the source repository and is never built or executed. +A few collaborations are written as statically resolvable type calls so the bounded Semantic C3 +extractor can exercise its confidence policy deterministically. The real repository remains the +architectural reference; this fixture is the reproducible offline golden. + +`TelemetryNames` is deliberate negative evidence: it must not become a C3 component. diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Ingestion.Api.csproj b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Ingestion.Api.csproj new file mode 100644 index 00000000..e13f0dcc --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Ingestion.Api.csproj @@ -0,0 +1,6 @@ + + net10.0 + + + + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Program.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Program.cs new file mode 100644 index 00000000..ceda787f --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Program.cs @@ -0,0 +1,7 @@ +using Ingestion.Api.Values; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddScoped(); +var app = builder.Build(); +app.MapPost("/values", (IngestValueHandler handler) => IngestValueHandler.Handle()); +app.Run(); diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/IngestValueHandler.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/IngestValueHandler.cs new file mode 100644 index 00000000..1038728b --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/IngestValueHandler.cs @@ -0,0 +1,14 @@ +using Ingestion.Persistence; + +namespace Ingestion.Api.Values; + +public sealed class IngestValueHandler( + IngestionDbContext database, + RedisIdempotencyStore cache) +{ + public void Handle() + { + IngestionDbContext.Save(); + RedisIdempotencyStore.Store(); + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/RedisIdempotencyStore.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/RedisIdempotencyStore.cs new file mode 100644 index 00000000..0b530569 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/RedisIdempotencyStore.cs @@ -0,0 +1,8 @@ +namespace Ingestion.Api.Values; + +public sealed class RedisIdempotencyStore +{ + public static void Store() + { + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/TelemetryNames.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/TelemetryNames.cs new file mode 100644 index 00000000..418fefc3 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Api/Values/TelemetryNames.cs @@ -0,0 +1,6 @@ +namespace Ingestion.Api.Values; + +public static class TelemetryNames +{ + public const string Source = "Ingestion.Api"; +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/Ingestion.Persistence.csproj b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/Ingestion.Persistence.csproj new file mode 100644 index 00000000..23bd5b95 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/Ingestion.Persistence.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/IngestionDbContext.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/IngestionDbContext.cs new file mode 100644 index 00000000..3ac05a5d --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Persistence/IngestionDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace Ingestion.Persistence; + +public sealed class IngestionDbContext(DbContextOptions options) + : DbContext(options) +{ + public static void Save() + { + } +} From 8735ffb1583fd9b19a067802e3291e6fae69d13c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:25:37 -0300 Subject: [PATCH 49/74] test: add outbox and consolidation dogfood sources (#69) --- .../Consolidated/ConsolidatedQuery.cs | 11 +++++++++++ .../Consolidation.Api/Consolidation.Api.csproj | 6 ++++++ .../src/Consolidation.Api/Program.cs | 7 +++++++ .../Consolidation.Persistence.csproj | 3 +++ .../ConsolidationDbContext.cs | 15 +++++++++++++++ .../Ingestion.Outbox.Worker.csproj | 6 ++++++ .../OutboxBackgroundService.cs | 14 ++++++++++++++ .../src/Ingestion.Outbox.Worker/Program.cs | 3 +++ .../RabbitMqOutboxMessagePublisher.cs | 6 ++++++ 9 files changed, 71 insertions(+) create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidated/ConsolidatedQuery.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidation.Api.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Program.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/Consolidation.Persistence.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/ConsolidationDbContext.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/OutboxBackgroundService.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Program.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/RabbitMqOutboxMessagePublisher.cs diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidated/ConsolidatedQuery.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidated/ConsolidatedQuery.cs new file mode 100644 index 00000000..edd9f703 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidated/ConsolidatedQuery.cs @@ -0,0 +1,11 @@ +using Consolidation.Persistence; + +namespace Consolidation.Api.Consolidated; + +public sealed class ConsolidatedQuery(ConsolidationDbContext database) +{ + public static void Get() + { + ConsolidationDbContext.Read(); + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidation.Api.csproj b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidation.Api.csproj new file mode 100644 index 00000000..eabafa22 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Consolidation.Api.csproj @@ -0,0 +1,6 @@ + + net10.0 + + + + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Program.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Program.cs new file mode 100644 index 00000000..12cbc6d7 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Api/Program.cs @@ -0,0 +1,7 @@ +using Consolidation.Api.Consolidated; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddScoped(); +var app = builder.Build(); +app.MapGet("/consolidated", (ConsolidatedQuery query) => ConsolidatedQuery.Get()); +app.Run(); diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/Consolidation.Persistence.csproj b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/Consolidation.Persistence.csproj new file mode 100644 index 00000000..23bd5b95 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/Consolidation.Persistence.csproj @@ -0,0 +1,3 @@ + + net10.0 + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/ConsolidationDbContext.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/ConsolidationDbContext.cs new file mode 100644 index 00000000..54bff768 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Persistence/ConsolidationDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace Consolidation.Persistence; + +public sealed class ConsolidationDbContext(DbContextOptions options) + : DbContext(options) +{ + public static void Read() + { + } + + public static void Save() + { + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj new file mode 100644 index 00000000..e81e6160 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj @@ -0,0 +1,6 @@ + + net10.0 + + + + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/OutboxBackgroundService.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/OutboxBackgroundService.cs new file mode 100644 index 00000000..0d1c448a --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/OutboxBackgroundService.cs @@ -0,0 +1,14 @@ +using Ingestion.Persistence; +using Microsoft.Extensions.Hosting; + +public sealed class OutboxBackgroundService( + RabbitMqOutboxMessagePublisher publisher, + IngestionDbContext database) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + RabbitMqOutboxMessagePublisher.Publish(); + IngestionDbContext.Save(); + return Task.CompletedTask; + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Program.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Program.cs new file mode 100644 index 00000000..a80bb89f --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/Program.cs @@ -0,0 +1,3 @@ +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); diff --git a/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/RabbitMqOutboxMessagePublisher.cs b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/RabbitMqOutboxMessagePublisher.cs new file mode 100644 index 00000000..56c75650 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Ingestion.Outbox.Worker/RabbitMqOutboxMessagePublisher.cs @@ -0,0 +1,6 @@ +public sealed class RabbitMqOutboxMessagePublisher +{ + public static void Publish() + { + } +} From 530fc1333051411dc0c1908f79e57f81bcfecdfe Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:26:15 -0300 Subject: [PATCH 50/74] test: complete semantic C3 dogfood fixture (#69) --- .../semantic-c3-dogfood/inspection-v1.6.json | 58 +++++++++++++++++++ .../Consolidation.Worker.csproj | 6 ++ .../ConsolidationConsumer.cs | 11 ++++ .../ConsolidationProcessor.cs | 9 +++ .../src/Consolidation.Worker/Program.cs | 3 + 5 files changed, 87 insertions(+) create mode 100644 examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Consolidation.Worker.csproj create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationConsumer.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationProcessor.cs create mode 100644 examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Program.cs diff --git a/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json b/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json new file mode 100644 index 00000000..f7aaa6ec --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": "1.6", + "repository": { + "name": "semantic-c3-dogfood", + "commitSha": "66b1dc7c789c96c521d08d7012155cf309f3d3f2" + }, + "projects": [ + {"path":"src/Ingestion.Api/Ingestion.Api.csproj"}, + {"path":"src/Ingestion.Persistence/Ingestion.Persistence.csproj"}, + {"path":"src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj"}, + {"path":"src/Consolidation.Api/Consolidation.Api.csproj"}, + {"path":"src/Consolidation.Persistence/Consolidation.Persistence.csproj"}, + {"path":"src/Consolidation.Worker/Consolidation.Worker.csproj"} + ], + "integrations": [ + { + "id":"integration-dogfood-ingestion-db", + "projectPath":"src/Ingestion.Persistence/Ingestion.Persistence.csproj", + "kind":"database","direction":"bidirectional","technology":"postgresql", + "target":"ingestion_db","resourceType":"database", + "source":{"path":"src/Ingestion.Persistence/IngestionDbContext.cs","line":5}, + "confidence":"high","signals":["database:postgresql"] + }, + { + "id":"integration-dogfood-redis", + "projectPath":"src/Ingestion.Api/Ingestion.Api.csproj", + "kind":"cache","direction":"bidirectional","technology":"redis", + "target":"ingestion-idempotency","resourceType":"cache", + "source":{"path":"src/Ingestion.Api/Values/RedisIdempotencyStore.cs","line":3}, + "confidence":"high","signals":["cache:redis"] + }, + { + "id":"integration-dogfood-rabbit-publish", + "projectPath":"src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj", + "kind":"messaging","direction":"publish","technology":"rabbitmq", + "target":"lab.events.v1","resourceType":"exchange", + "source":{"path":"src/Ingestion.Outbox.Worker/RabbitMqOutboxMessagePublisher.cs","line":1}, + "confidence":"high","signals":["rabbitmq:publish"] + }, + { + "id":"integration-dogfood-consolidation-db", + "projectPath":"src/Consolidation.Persistence/Consolidation.Persistence.csproj", + "kind":"database","direction":"bidirectional","technology":"postgresql", + "target":"consolidation_db","resourceType":"database", + "source":{"path":"src/Consolidation.Persistence/ConsolidationDbContext.cs","line":5}, + "confidence":"high","signals":["database:postgresql"] + }, + { + "id":"integration-dogfood-rabbit-consume", + "projectPath":"src/Consolidation.Worker/Consolidation.Worker.csproj", + "kind":"messaging","direction":"consume","technology":"rabbitmq", + "target":"consolidation.value-received.v1","resourceType":"queue", + "source":{"path":"src/Consolidation.Worker/ConsolidationConsumer.cs","line":3}, + "confidence":"high","signals":["rabbitmq:consume"] + } + ], + "integrationDiscovery":{"enabled":true,"completed":true,"truncated":false} +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Consolidation.Worker.csproj b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Consolidation.Worker.csproj new file mode 100644 index 00000000..abbe4c09 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Consolidation.Worker.csproj @@ -0,0 +1,6 @@ + + net10.0 + + + + diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationConsumer.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationConsumer.cs new file mode 100644 index 00000000..8bdc8c63 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationConsumer.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Hosting; + +public sealed class ConsolidationConsumer(ConsolidationProcessor processor) + : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + ConsolidationProcessor.Process(); + return Task.CompletedTask; + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationProcessor.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationProcessor.cs new file mode 100644 index 00000000..9e9efde3 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/ConsolidationProcessor.cs @@ -0,0 +1,9 @@ +using Consolidation.Persistence; + +public sealed class ConsolidationProcessor(ConsolidationDbContext database) +{ + public static void Process() + { + ConsolidationDbContext.Save(); + } +} diff --git a/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Program.cs b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Program.cs new file mode 100644 index 00000000..047f1731 --- /dev/null +++ b/examples/fixtures/semantic-c3-dogfood/src/Consolidation.Worker/Program.cs @@ -0,0 +1,3 @@ +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); From d5b08f99c9cac755baaa468408a2235f29eca7d2 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:26:47 -0300 Subject: [PATCH 51/74] test: add semantic C3 dogfood model builder (#69) --- scripts/build-semantic-c3-dogfood-model.py | 129 +++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100755 scripts/build-semantic-c3-dogfood-model.py diff --git a/scripts/build-semantic-c3-dogfood-model.py b/scripts/build-semantic-c3-dogfood-model.py new file mode 100755 index 00000000..f708538b --- /dev/null +++ b/scripts/build-semantic-c3-dogfood-model.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Build the reviewed C2 model around an inspected semantic-c3 dogfood snapshot.""" + +import json +import sys +from pathlib import Path + +if len(sys.argv) != 3: + raise SystemExit("usage: build-semantic-c3-dogfood-model.py SNAPSHOT OUTPUT") + +snapshot = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + +def evidence_id(category, path): + matches = [ + item["id"] for item in snapshot["evidence"] + if item["category"] == category and item["relativePath"] == path + ] + if len(matches) != 1: + raise SystemExit(f"expected one {category} evidence for {path}, got {len(matches)}") + return matches[0] + +def external(kind, technology, project): + findings = snapshot.get("externalIntegrationEvidence", {}).get("evidence", []) + matches = [ + item["id"] for item in findings + if item["kind"] == kind + and item["technology"] == technology + and item["projectPath"] == project + ] + if len(matches) != 1: + raise SystemExit( + f"expected one external {kind}/{technology} finding for {project}, got {len(matches)}" + ) + return matches[0] + +projects = { + "ingestion_api": "src/Ingestion.Api/Ingestion.Api.csproj", + "ingestion_persistence": "src/Ingestion.Persistence/Ingestion.Persistence.csproj", + "outbox": "src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj", + "consolidation_api": "src/Consolidation.Api/Consolidation.Api.csproj", + "consolidation_persistence": "src/Consolidation.Persistence/Consolidation.Persistence.csproj", + "consolidation_worker": "src/Consolidation.Worker/Consolidation.Worker.csproj", +} +project_evidence = { + key: evidence_id("dotnet.project", value) for key, value in projects.items() +} + +ingestion_db = external("database", "postgresql", projects["ingestion_persistence"]) +redis = external("cache", "redis", projects["ingestion_api"]) +rabbit_publish = external("messaging", "rabbitmq", projects["outbox"]) +consolidation_db = external("database", "postgresql", projects["consolidation_persistence"]) +rabbit_consume = external("messaging", "rabbitmq", projects["consolidation_worker"]) + +def container(identifier, name, evidence_ids): + return { + "id": identifier, + "kind": "container", + "name": name, + "parentId": "el_lab", + "evidenceIds": evidence_ids, + "status": "requiresReview", + "reviewReason": "Dogfood C2 boundary is reviewed separately from static Semantic C3 discovery.", + } + +def external_element(identifier, name, evidence_ids): + return { + "id": identifier, + "kind": "softwareSystem", + "name": name, + "evidenceIds": evidence_ids, + "status": "requiresReview", + "reviewReason": "External resource identity is normalized from static integration evidence.", + } + +def relation(identifier, source, destination, description, evidence_id_value): + return { + "id": identifier, + "sourceId": source, + "destinationId": destination, + "description": description, + "evidenceIds": [evidence_id_value], + "status": "requiresReview", + "reviewReason": "Dogfood runtime relation remains reviewable.", + } + +model = { + "schemaVersion": "1.0", + "level": "C2", + "snapshot": snapshot, + "elements": [ + { + "id": "el_lab", + "kind": "softwareSystem", + "name": "dotnet-observability-lab", + "evidenceIds": [], + "status": "requiresReview", + "reviewReason": "Fixture system scope mirrors the documented dogfood repository.", + }, + container("el_ingestion_api", "Ingestion.Api", + [project_evidence["ingestion_api"], project_evidence["ingestion_persistence"]]), + container("el_ingestion_outbox_worker", "Ingestion.Outbox.Worker", + [project_evidence["outbox"], project_evidence["ingestion_persistence"]]), + container("el_consolidation_api", "Consolidation.Api", + [project_evidence["consolidation_api"], project_evidence["consolidation_persistence"]]), + container("el_consolidation_worker", "Consolidation.Worker", + [project_evidence["consolidation_worker"], project_evidence["consolidation_persistence"]]), + external_element("el_postgresql", "PostgreSQL", [ingestion_db, consolidation_db]), + external_element("el_redis", "Redis", [redis]), + external_element("el_rabbitmq", "RabbitMQ", [rabbit_publish, rabbit_consume]), + ], + "relations": [ + relation("rel_ingestion_api_postgresql", "el_ingestion_api", "el_postgresql", + "Uses ingestion_db", ingestion_db), + relation("rel_ingestion_api_redis", "el_ingestion_api", "el_redis", + "Uses Redis idempotency cache", redis), + relation("rel_outbox_postgresql", "el_ingestion_outbox_worker", "el_postgresql", + "Claims ingestion Outbox", ingestion_db), + relation("rel_outbox_rabbitmq", "el_ingestion_outbox_worker", "el_rabbitmq", + "Publishes integration events", rabbit_publish), + relation("rel_consolidation_api_postgresql", "el_consolidation_api", "el_postgresql", + "Reads consolidation_db", consolidation_db), + relation("rel_rabbitmq_consolidation_worker", "el_rabbitmq", "el_consolidation_worker", + "Delivers integration events", rabbit_consume), + relation("rel_consolidation_worker_postgresql", "el_consolidation_worker", "el_postgresql", + "Commits Inbox and aggregate", consolidation_db), + ], +} + +Path(sys.argv[2]).write_text(json.dumps(model, indent=2) + "\n", encoding="utf-8") From 87ae35745d61901d6fa6ab2484d2bfa34976a0c2 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:29:04 -0300 Subject: [PATCH 52/74] test: validate semantic C3 against observability dogfood golden (#69) --- .../Repo2C4.Core.Tests.csproj | 3 + .../SemanticC3DogfoodTests.cs | 387 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs diff --git a/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj b/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj index 974c32bd..cd69c9e9 100644 --- a/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj +++ b/tests/Repo2C4.Core.Tests/Repo2C4.Core.Tests.csproj @@ -36,5 +36,8 @@ + diff --git a/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs new file mode 100644 index 00000000..753e9501 --- /dev/null +++ b/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs @@ -0,0 +1,387 @@ +using System.Collections.Immutable; +using Repo2C4.Core.C3; +using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; +using Repo2C4.Core.Inspection; +using Repo2C4.Core.LikeC4; +using Repo2C4.Core.Review; +using Xunit; + +namespace Repo2C4.Core.Tests; + +public sealed class SemanticC3DogfoodTests +{ + private static readonly string[] SelectedContainers = + [ + "el_ingestion_api", + "el_ingestion_outbox_worker", + "el_consolidation_api", + "el_consolidation_worker", + ]; + + [Fact] + public async Task DotNetObservabilityGoldenProducesUsefulBoundedSemanticC3() + { + string root = Path.Combine(AppContext.BaseDirectory, "SemanticC3Dogfood"); + RepositorySnapshot inspected = RepositoryFactExtractor.Extract( + new RepositoryScanOptions(root, "semantic_c3_dogfood"), + TestContext.Current.CancellationToken); + + Assert.NotNull(inspected.SemanticC3Facts); + Assert.NotEmpty(inspected.SemanticC3Facts!.Facts); + + await using FileStream report = File.OpenRead( + Path.Combine(root, "inspection-v1.6.json")); + ExternalIntegrationSnapshotImportResult imported = + await ExternalIntegrationSnapshotImporter.ImportAsync( + inspected, + report, + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(imported.Succeeded); + Assert.NotNull(imported.Snapshot.ExternalIntegrationEvidence); + + // Persistence through the public v1 JSON contract is required for CLI/offline generation. + RepositorySnapshot roundTripped = ContractJson.DeserializeSnapshot( + ContractJson.SerializeSnapshot(imported.Snapshot)); + Assert.NotNull(roundTripped.SemanticC3Facts); + Assert.NotNull(roundTripped.ExternalIntegrationEvidence); + + ArchitectureModel model = CreateReviewedModel(roundTripped); + ArchitectureC3BuildResult build = ArchitectureC3Builder.BuildManyDetailed( + model, + SelectedContainers); + + Assert.NotNull(build.SemanticResult); + Assert.Equal(4, build.Workspace.Selections.Length); + Assert.Equal(SelectedContainers, build.Workspace.Selections.Select(item => item.SelectedContainerId)); + + SemanticC3Proposal proposal = build.SemanticResult!.Proposal; + AssertResponsibilities( + proposal, + "el_ingestion_api", + SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter, + SemanticC3ComponentCategory.IntegrationAdapter); + AssertResponsibilities( + proposal, + "el_ingestion_outbox_worker", + SemanticC3ComponentCategory.BackgroundWorker, + SemanticC3ComponentCategory.MessagingPublisher, + SemanticC3ComponentCategory.PersistenceAdapter); + AssertResponsibilities( + proposal, + "el_consolidation_api", + SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter); + AssertResponsibilities( + proposal, + "el_consolidation_worker", + SemanticC3ComponentCategory.BackgroundWorker, + SemanticC3ComponentCategory.MessagingConsumer, + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter); + + Assert.All( + build.Workspace.Selections, + selection => Assert.InRange(selection.Components.Length, 1, 6)); + Assert.DoesNotContain( + proposal.Components, + component => component.Name.Contains("Telemetry", StringComparison.OrdinalIgnoreCase)); + + AssertInternalFlow( + proposal, + "el_ingestion_api", + SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService); + AssertInternalFlow( + proposal, + "el_ingestion_api", + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter); + AssertInternalFlow( + proposal, + "el_ingestion_api", + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.IntegrationAdapter); + AssertInternalFlow( + proposal, + "el_ingestion_outbox_worker", + SemanticC3ComponentCategory.BackgroundWorker, + SemanticC3ComponentCategory.MessagingPublisher); + AssertInternalFlow( + proposal, + "el_consolidation_api", + SemanticC3ComponentCategory.HttpEndpoint, + SemanticC3ComponentCategory.ApplicationService); + AssertInternalFlow( + proposal, + "el_consolidation_api", + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter); + AssertInternalFlow( + proposal, + "el_consolidation_worker", + SemanticC3ComponentCategory.MessagingConsumer, + SemanticC3ComponentCategory.ApplicationService); + AssertInternalFlow( + proposal, + "el_consolidation_worker", + SemanticC3ComponentCategory.ApplicationService, + SemanticC3ComponentCategory.PersistenceAdapter); + + Assert.Contains(proposal.Relations, relation => + relation.DestinationKind == SemanticC3RelationTargetKind.ArchitectureElement && + relation.DestinationId == "el_redis" && + Component(proposal, relation.SourceComponentId).ContainerId == "el_ingestion_api"); + Assert.DoesNotContain(proposal.Relations, relation => + relation.DestinationId == "el_redis" && + Component(proposal, relation.SourceComponentId).ContainerId == "el_consolidation_worker"); + Assert.Contains(proposal.Relations, relation => + relation.DestinationId == "el_rabbitmq" && + Component(proposal, relation.SourceComponentId).ContainerId == "el_ingestion_outbox_worker"); + Assert.Contains(proposal.Relations, relation => + relation.DestinationId == "el_rabbitmq" && + Component(proposal, relation.SourceComponentId).ContainerId == "el_consolidation_worker"); + Assert.Contains(proposal.Relations, relation => + relation.DestinationId == "el_postgresql"); + + Dictionary facts = roundTripped.SemanticC3Facts!.Facts + .ToDictionary(item => item.Id, StringComparer.Ordinal); + foreach (SemanticC3RelationCandidate relation in proposal.Relations.Where(item => + item.Status == ReviewStatus.Confirmed && + item.DestinationKind == SemanticC3RelationTargetKind.Component)) + { + SemanticC3Fact[] relationFacts = + [ + .. relation.EvidenceIds + .Where(facts.ContainsKey) + .Select(id => facts[id]), + ]; + Assert.Contains(relationFacts, fact => + fact.Kind == SemanticC3FactKind.SymbolInvocation); + Assert.Contains(relationFacts, fact => + fact.Kind is SemanticC3FactKind.ConstructorInjection + or SemanticC3FactKind.EndpointDependency + or SemanticC3FactKind.MethodParameter + or SemanticC3FactKind.DependencyInjectionRegistration + or SemanticC3FactKind.EndpointHandler); + } + + IReadOnlyList generated = + LikeC4Emitter.EmitWithC3(build.Workspace); + string views = Assert.Single( + generated, + file => file.FileName == LikeC4Emitter.C3ViewsFileName).Content; + Assert.Equal(4, views.Split("view c3_", StringSplitOptions.None).Length - 1); + + EvidenceReportResult reportResult = + EvidenceReportGenerator.GenerateSemanticC3( + model, + build.SemanticResult, + roundTripped.SemanticC3Facts); + Assert.Contains("semantic.host", reportResult.Content, StringComparison.Ordinal); + Assert.Contains("semantic.persistence", reportResult.Content, StringComparison.Ordinal); + Assert.DoesNotContain("Idempotency-Key", reportResult.Content, StringComparison.Ordinal); + + ArchitectureC3BuildResult repeated = ArchitectureC3Builder.BuildManyDetailed( + model, + SelectedContainers.Reverse()); + Assert.Equal( + SemanticC3ContractJson.Serialize(build.SemanticResult.Proposal), + SemanticC3ContractJson.Serialize(repeated.SemanticResult!.Proposal)); + } + + private static void AssertResponsibilities( + SemanticC3Proposal proposal, + string containerId, + params SemanticC3ComponentCategory[] expected) + { + SemanticC3ComponentCandidate[] components = + [ + .. proposal.Components.Where(item => item.ContainerId == containerId), + ]; + foreach (SemanticC3ComponentCategory category in expected) + { + Assert.Contains(components, component => component.Category == category); + } + } + + private static void AssertInternalFlow( + SemanticC3Proposal proposal, + string containerId, + SemanticC3ComponentCategory sourceCategory, + SemanticC3ComponentCategory destinationCategory) + { + HashSet sources = + [ + .. proposal.Components + .Where(component => + component.ContainerId == containerId && + component.Category == sourceCategory) + .Select(component => component.Id), + ]; + HashSet destinations = + [ + .. proposal.Components + .Where(component => + component.ContainerId == containerId && + component.Category == destinationCategory) + .Select(component => component.Id), + ]; + + Assert.Contains(proposal.Relations, relation => + relation.DestinationKind == SemanticC3RelationTargetKind.Component && + sources.Contains(relation.SourceComponentId) && + destinations.Contains(relation.DestinationId)); + } + + private static SemanticC3ComponentCandidate Component( + SemanticC3Proposal proposal, + string componentId) => + Assert.Single(proposal.Components, component => component.Id == componentId); + + private static ArchitectureModel CreateReviewedModel(RepositorySnapshot snapshot) + { + string Project(string path) => Assert.Single( + snapshot.Evidence, + evidence => + evidence.Category == "dotnet.project" && + evidence.RelativePath == path).Id; + + ExternalIntegrationEvidence[] external = + snapshot.ExternalIntegrationEvidence!.Evidence.ToArray(); + + string External( + ExternalIntegrationKind kind, + string technology, + string projectPath) => + Assert.Single(external, item => + item.Kind == kind && + item.Technology == technology && + item.ProjectPath == projectPath).Id; + + string ingestionApiProject = "src/Ingestion.Api/Ingestion.Api.csproj"; + string ingestionPersistenceProject = "src/Ingestion.Persistence/Ingestion.Persistence.csproj"; + string outboxProject = "src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj"; + string consolidationApiProject = "src/Consolidation.Api/Consolidation.Api.csproj"; + string consolidationPersistenceProject = "src/Consolidation.Persistence/Consolidation.Persistence.csproj"; + string consolidationWorkerProject = "src/Consolidation.Worker/Consolidation.Worker.csproj"; + + string ingestionDb = External( + ExternalIntegrationKind.Database, + "postgresql", + ingestionPersistenceProject); + string redis = External( + ExternalIntegrationKind.Cache, + "redis", + ingestionApiProject); + string rabbitPublish = External( + ExternalIntegrationKind.Messaging, + "rabbitmq", + outboxProject); + string consolidationDb = External( + ExternalIntegrationKind.Database, + "postgresql", + consolidationPersistenceProject); + string rabbitConsume = External( + ExternalIntegrationKind.Messaging, + "rabbitmq", + consolidationWorkerProject); + + ArchitectureElement[] elements = + [ + new( + "el_lab", + ArchitectureElementKind.SoftwareSystem, + "dotnet-observability-lab", + null, + [], + ReviewStatus.RequiresReview, + "Fixture scope mirrors the documented dogfood repository."), + Container( + "el_ingestion_api", + "Ingestion.Api", + Project(ingestionApiProject), + Project(ingestionPersistenceProject)), + Container( + "el_ingestion_outbox_worker", + "Ingestion.Outbox.Worker", + Project(outboxProject), + Project(ingestionPersistenceProject)), + Container( + "el_consolidation_api", + "Consolidation.Api", + Project(consolidationApiProject), + Project(consolidationPersistenceProject)), + Container( + "el_consolidation_worker", + "Consolidation.Worker", + Project(consolidationWorkerProject), + Project(consolidationPersistenceProject)), + ExternalElement("el_postgresql", "PostgreSQL", ingestionDb, consolidationDb), + ExternalElement("el_redis", "Redis", redis), + ExternalElement("el_rabbitmq", "RabbitMQ", rabbitPublish, rabbitConsume), + ]; + + ArchitectureRelation[] relations = + [ + Relation("rel_ingestion_api_postgresql", "el_ingestion_api", "el_postgresql", "Uses ingestion_db", ingestionDb), + Relation("rel_ingestion_api_redis", "el_ingestion_api", "el_redis", "Uses Redis idempotency cache", redis), + Relation("rel_outbox_postgresql", "el_ingestion_outbox_worker", "el_postgresql", "Claims ingestion Outbox", ingestionDb), + Relation("rel_outbox_rabbitmq", "el_ingestion_outbox_worker", "el_rabbitmq", "Publishes integration events", rabbitPublish), + Relation("rel_consolidation_api_postgresql", "el_consolidation_api", "el_postgresql", "Reads consolidation_db", consolidationDb), + Relation("rel_rabbitmq_consolidation_worker", "el_rabbitmq", "el_consolidation_worker", "Delivers integration events", rabbitConsume), + Relation("rel_consolidation_worker_postgresql", "el_consolidation_worker", "el_postgresql", "Commits Inbox and aggregate", consolidationDb), + ]; + + return new ArchitectureModel( + ContractSchema.Version, + ArchitectureLevel.C2, + snapshot, + [.. elements], + [.. relations]); + } + + private static ArchitectureElement Container( + string id, + string name, + params string[] evidenceIds) => + new( + id, + ArchitectureElementKind.Container, + name, + "el_lab", + [.. evidenceIds], + ReviewStatus.RequiresReview, + "C2 deployment boundary is reviewed separately from static Semantic C3."); + + private static ArchitectureElement ExternalElement( + string id, + string name, + params string[] evidenceIds) => + new( + id, + ArchitectureElementKind.SoftwareSystem, + name, + null, + [.. evidenceIds], + ReviewStatus.RequiresReview, + "External resource identity is normalized from static integration evidence."); + + private static ArchitectureRelation Relation( + string id, + string source, + string destination, + string description, + string evidenceId) => + new( + id, + source, + destination, + description, + [evidenceId], + ReviewStatus.RequiresReview, + "Dogfood runtime relation remains reviewable."); +} From cad12b7644b53de23961fe22c2a2f91baf652f8f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:30:22 -0300 Subject: [PATCH 53/74] fix: compare reviewed snapshots on stable public surface (#69) --- scripts/architecture-pr.py | 13 +++++++++++-- tests/automation/test_architecture_pr.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) mode change 100644 => 100755 scripts/architecture-pr.py diff --git a/scripts/architecture-pr.py b/scripts/architecture-pr.py old mode 100644 new mode 100755 index 928a6331..09162956 --- a/scripts/architecture-pr.py +++ b/scripts/architecture-pr.py @@ -135,6 +135,14 @@ def safe_output_root(root: Path, output_id: str) -> Path: return output +def public_snapshot(snapshot: dict) -> dict: + """Return the stable v1 snapshot surface used for reviewed-model freshness checks.""" + projected = dict(snapshot) + projected.pop("semanticC3Facts", None) + projected.pop("externalIntegrationEvidence", None) + return projected + + def prepare(args: argparse.Namespace, root: Path) -> bool: repository_root, model_path = validate_options(args, root) output = safe_output_root(root, args.output_id) @@ -159,8 +167,9 @@ def prepare(args: argparse.Namespace, root: Path) -> bool: "--provider", "openai", "--model-id", args.model_id, "--allow-external-ai", "--output", str(candidate), cwd=root) model_path = candidate - elif json.loads(model_path.read_text(encoding="utf-8")).get("snapshot") != json.loads( - snapshot.read_text(encoding="utf-8")): + elif public_snapshot( + json.loads(model_path.read_text(encoding="utf-8")).get("snapshot", {})) != public_snapshot( + json.loads(snapshot.read_text(encoding="utf-8"))): raise AutomationError("Reviewed model snapshot differs from the freshly inspected authorized repository.") command("dotnet", cli, "generate", "--model", str(model_path), "--output", str(output), cwd=root) diff --git a/tests/automation/test_architecture_pr.py b/tests/automation/test_architecture_pr.py index d1241120..e34a0f60 100644 --- a/tests/automation/test_architecture_pr.py +++ b/tests/automation/test_architecture_pr.py @@ -152,6 +152,23 @@ def test_cloud_consent_and_repository_guards_run_before_network(self) -> None: with self.assertRaisesRegex(automation.AutomationError, "repository differs"): automation.validate_options(untrusted, self.root) + def test_public_snapshot_ignores_additive_semantic_metadata(self) -> None: + public = { + "schemaVersion": "1.0", + "repositoryId": "repo", + "files": [], + "evidence": [], + "diagnostics": [], + } + enriched = dict(public) + enriched["semanticC3Facts"] = {"schemaVersion": "1.0", "facts": [], "diagnostics": []} + enriched["externalIntegrationEvidence"] = {"evidence": [], "diagnostics": []} + + self.assertEqual( + automation.public_snapshot(public), + automation.public_snapshot(enriched), + ) + def test_unmatched_reviewed_snapshot_blocks_generation(self) -> None: (self.root / "models" / "reviewed.json").write_text( json.dumps({"snapshot": {**SNAPSHOT, "repositoryId": "other"}}), encoding="utf-8" From c4c29c88f79a7b8544dc7864dac727244991875b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:32:13 -0300 Subject: [PATCH 54/74] test: add four-container semantic C3 CLI smoke (#69) --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c486da9..3fcdf50f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -419,6 +419,45 @@ jobs: grep -Fq "component" "$workdir/c3-multi/model.c4" "${cli[@]}" validate --output "$workdir/c3-multi" + "${cli[@]}" inspect \ + --repository examples/fixtures/semantic-c3-dogfood \ + --integration-report inspection-v1.6.json \ + --output "$workdir/dogfood.snapshot.json" + python3 scripts/build-semantic-c3-dogfood-model.py \ + "$workdir/dogfood.snapshot.json" \ + "$workdir/dogfood.c2.json" + "${cli[@]}" generate \ + --model "$workdir/dogfood.c2.json" \ + --output "$workdir/dogfood" \ + --c3-container el_ingestion_api \ + --c3-container el_ingestion_outbox_worker \ + --c3-container el_consolidation_api \ + --c3-container el_consolidation_worker \ + --apply + test -s "$workdir/dogfood/semantic-c3-evidence-report.md" + test "$(grep -Ec '^[[:space:]]*view c3_' "$workdir/dogfood/c3.views.c4")" -eq 4 + grep -Fq 'ApplicationService' "$workdir/dogfood/semantic-c3-evidence-report.md" + grep -Fq 'PersistenceAdapter' "$workdir/dogfood/semantic-c3-evidence-report.md" + grep -Fq 'MessagingConsumer' "$workdir/dogfood/semantic-c3-evidence-report.md" + grep -Fq 'MessagingPublisher' "$workdir/dogfood/semantic-c3-evidence-report.md" + grep -Fq 'IntegrationAdapter' "$workdir/dogfood/semantic-c3-evidence-report.md" + if grep -Fq 'TelemetryNames' "$workdir/dogfood/model.c4"; then + echo "Semantic C3 dogfood promoted a utility type." + exit 1 + fi + "${cli[@]}" validate --output "$workdir/dogfood" + + dogfood_preview="$("${cli[@]}" generate \ + --model "$workdir/dogfood.c2.json" \ + --output "$workdir/dogfood" \ + --c3-container el_consolidation_worker \ + --c3-container el_ingestion_api \ + --c3-container el_consolidation_api \ + --c3-container el_ingestion_outbox_worker)" + [[ "$dogfood_preview" == *"unchanged model.c4"* ]] + [[ "$dogfood_preview" == *"unchanged c3.views.c4"* ]] + [[ "$dogfood_preview" == *"unchanged semantic-c3-evidence-report.md"* ]] + preview="$("${cli[@]}" generate \ --model examples/end-to-end/architecture.c2.v1.json \ --output "$workdir/c2")" From 986c771ebd1e74b560ab2219d000e59ac05e0150 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 05:33:04 -0300 Subject: [PATCH 55/74] test: dogfood semantic C3 through real MCP inspection (#69) --- .../McpSemanticC3DogfoodTests.cs | 197 ++++++++++++++++++ .../Repo2C4.Mcp.Tests.csproj | 3 + 2 files changed, 200 insertions(+) create mode 100644 tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs diff --git a/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs b/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs new file mode 100644 index 00000000..125d1f8e --- /dev/null +++ b/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs @@ -0,0 +1,197 @@ +using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; +using Xunit; + +namespace Repo2C4.Mcp.Tests; + +public sealed class McpSemanticC3DogfoodTests +{ + private static readonly string[] SelectedContainers = + [ + "el_ingestion_api", + "el_ingestion_outbox_worker", + "el_consolidation_api", + "el_consolidation_worker", + ]; + + [Fact] + public async Task RealInspectionFeedsFourContainerSemanticC3WithoutExposingInternalFacts() + { + string root = Path.Combine(AppContext.BaseDirectory, "SemanticC3Dogfood"); + using McpSnapshotStore store = new(); + McpArchitectureTools architecture = new(root, store); + + McpInspectRepositoryResult inspected = await architecture.InspectRepository( + ".", + "inspection-v1.6.json", + cancellationToken: TestContext.Current.CancellationToken); + + McpSnapshotStore.SnapshotEntry entry = store.Get(inspected.SnapshotId); + Assert.NotNull(entry.Snapshot.SemanticC3Facts); + Assert.NotNull(entry.Snapshot.ExternalIntegrationEvidence); + + RepositorySnapshot publicSnapshot = entry.Snapshot with + { + SemanticC3Facts = null, + ExternalIntegrationEvidence = null, + }; + ArchitectureModel reviewed = CreateReviewedModel( + publicSnapshot, + entry.Snapshot.ExternalIntegrationEvidence!); + + McpLikeC4Tools tools = new(root, store); + McpGenerateLikeC4Result generated = await tools.GenerateLikeC4( + inspected.SnapshotId, + reviewed, + c3Containers: SelectedContainers, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(generated.DryRun); + Assert.False(generated.Written); + Assert.Equal(SelectedContainers, generated.C3Views.Select(view => view.ContainerId)); + Assert.Equal(4, generated.C3Views.Length); + + McpLikeC4File semanticReport = Assert.Single( + generated.Files, + file => file.FileName == "semantic-c3-evidence-report.md"); + Assert.Contains("ApplicationService", semanticReport.Content, StringComparison.Ordinal); + Assert.Contains("PersistenceAdapter", semanticReport.Content, StringComparison.Ordinal); + Assert.Contains("MessagingConsumer", semanticReport.Content, StringComparison.Ordinal); + Assert.Contains("MessagingPublisher", semanticReport.Content, StringComparison.Ordinal); + Assert.Contains("IntegrationAdapter", semanticReport.Content, StringComparison.Ordinal); + Assert.DoesNotContain("Idempotency-Key", semanticReport.Content, StringComparison.Ordinal); + + McpLikeC4File model = Assert.Single( + generated.Files, + file => file.FileName == "model.c4"); + Assert.DoesNotContain("TelemetryNames", model.Content, StringComparison.Ordinal); + + // The model submitted by the client stays on the stable public snapshot surface. + Assert.Null(reviewed.Snapshot.SemanticC3Facts); + Assert.Null(reviewed.Snapshot.ExternalIntegrationEvidence); + } + + private static ArchitectureModel CreateReviewedModel( + RepositorySnapshot publicSnapshot, + ExternalIntegrationEvidenceResult structured) + { + string Project(string path) => Assert.Single( + publicSnapshot.Evidence, + evidence => + evidence.Category == "dotnet.project" && + evidence.RelativePath == path).Id; + + string External( + ExternalIntegrationKind kind, + string technology, + string projectPath) => + Assert.Single(structured.Evidence, evidence => + evidence.Kind == kind && + evidence.Technology == technology && + evidence.ProjectPath == projectPath).Id; + + const string ingestionApiProject = "src/Ingestion.Api/Ingestion.Api.csproj"; + const string ingestionPersistenceProject = "src/Ingestion.Persistence/Ingestion.Persistence.csproj"; + const string outboxProject = "src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj"; + const string consolidationApiProject = "src/Consolidation.Api/Consolidation.Api.csproj"; + const string consolidationPersistenceProject = "src/Consolidation.Persistence/Consolidation.Persistence.csproj"; + const string consolidationWorkerProject = "src/Consolidation.Worker/Consolidation.Worker.csproj"; + + string ingestionDb = External( + ExternalIntegrationKind.Database, + "postgresql", + ingestionPersistenceProject); + string redis = External( + ExternalIntegrationKind.Cache, + "redis", + ingestionApiProject); + string rabbitPublish = External( + ExternalIntegrationKind.Messaging, + "rabbitmq", + outboxProject); + string consolidationDb = External( + ExternalIntegrationKind.Database, + "postgresql", + consolidationPersistenceProject); + string rabbitConsume = External( + ExternalIntegrationKind.Messaging, + "rabbitmq", + consolidationWorkerProject); + + return new ArchitectureModel( + ContractSchema.Version, + ArchitectureLevel.C2, + publicSnapshot, + [ + new ArchitectureElement( + "el_lab", + ArchitectureElementKind.SoftwareSystem, + "dotnet-observability-lab", + null, + [], + ReviewStatus.RequiresReview, + "Fixture scope mirrors the documented dogfood repository."), + Container("el_ingestion_api", "Ingestion.Api", + Project(ingestionApiProject), Project(ingestionPersistenceProject)), + Container("el_ingestion_outbox_worker", "Ingestion.Outbox.Worker", + Project(outboxProject), Project(ingestionPersistenceProject)), + Container("el_consolidation_api", "Consolidation.Api", + Project(consolidationApiProject), Project(consolidationPersistenceProject)), + Container("el_consolidation_worker", "Consolidation.Worker", + Project(consolidationWorkerProject), Project(consolidationPersistenceProject)), + ExternalElement("el_postgresql", "PostgreSQL", ingestionDb, consolidationDb), + ExternalElement("el_redis", "Redis", redis), + ExternalElement("el_rabbitmq", "RabbitMQ", rabbitPublish, rabbitConsume), + ], + [ + Relation("rel_ingestion_api_postgresql", "el_ingestion_api", "el_postgresql", "Uses ingestion_db", ingestionDb), + Relation("rel_ingestion_api_redis", "el_ingestion_api", "el_redis", "Uses Redis idempotency cache", redis), + Relation("rel_outbox_postgresql", "el_ingestion_outbox_worker", "el_postgresql", "Claims ingestion Outbox", ingestionDb), + Relation("rel_outbox_rabbitmq", "el_ingestion_outbox_worker", "el_rabbitmq", "Publishes integration events", rabbitPublish), + Relation("rel_consolidation_api_postgresql", "el_consolidation_api", "el_postgresql", "Reads consolidation_db", consolidationDb), + Relation("rel_rabbitmq_consolidation_worker", "el_rabbitmq", "el_consolidation_worker", "Delivers integration events", rabbitConsume), + Relation("rel_consolidation_worker_postgresql", "el_consolidation_worker", "el_postgresql", "Commits Inbox and aggregate", consolidationDb), + ]); + } + + private static ArchitectureElement Container( + string id, + string name, + params string[] evidenceIds) => + new( + id, + ArchitectureElementKind.Container, + name, + "el_lab", + [.. evidenceIds], + ReviewStatus.RequiresReview, + "C2 deployment boundary is reviewed separately from static Semantic C3."); + + private static ArchitectureElement ExternalElement( + string id, + string name, + params string[] evidenceIds) => + new( + id, + ArchitectureElementKind.SoftwareSystem, + name, + null, + [.. evidenceIds], + ReviewStatus.RequiresReview, + "External resource identity is normalized from static integration evidence."); + + private static ArchitectureRelation Relation( + string id, + string source, + string destination, + string description, + string evidenceId) => + new( + id, + source, + destination, + description, + [evidenceId], + ReviewStatus.RequiresReview, + "Dogfood runtime relation remains reviewable."); +} diff --git a/tests/Repo2C4.Mcp.Tests/Repo2C4.Mcp.Tests.csproj b/tests/Repo2C4.Mcp.Tests/Repo2C4.Mcp.Tests.csproj index f35d76cf..b8972060 100644 --- a/tests/Repo2C4.Mcp.Tests/Repo2C4.Mcp.Tests.csproj +++ b/tests/Repo2C4.Mcp.Tests/Repo2C4.Mcp.Tests.csproj @@ -24,5 +24,8 @@ + From 9725829d97858c511993604e275b70934a88122a Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:05:36 -0300 Subject: [PATCH 56/74] test: use valid integration IDs in semantic C3 dogfood (#69) --- .../fixtures/semantic-c3-dogfood/inspection-v1.6.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json b/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json index f7aaa6ec..f5b05da2 100644 --- a/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json +++ b/examples/fixtures/semantic-c3-dogfood/inspection-v1.6.json @@ -14,7 +14,7 @@ ], "integrations": [ { - "id":"integration-dogfood-ingestion-db", + "id":"integration-0000000000000001", "projectPath":"src/Ingestion.Persistence/Ingestion.Persistence.csproj", "kind":"database","direction":"bidirectional","technology":"postgresql", "target":"ingestion_db","resourceType":"database", @@ -22,7 +22,7 @@ "confidence":"high","signals":["database:postgresql"] }, { - "id":"integration-dogfood-redis", + "id":"integration-0000000000000002", "projectPath":"src/Ingestion.Api/Ingestion.Api.csproj", "kind":"cache","direction":"bidirectional","technology":"redis", "target":"ingestion-idempotency","resourceType":"cache", @@ -30,7 +30,7 @@ "confidence":"high","signals":["cache:redis"] }, { - "id":"integration-dogfood-rabbit-publish", + "id":"integration-0000000000000003", "projectPath":"src/Ingestion.Outbox.Worker/Ingestion.Outbox.Worker.csproj", "kind":"messaging","direction":"publish","technology":"rabbitmq", "target":"lab.events.v1","resourceType":"exchange", @@ -38,7 +38,7 @@ "confidence":"high","signals":["rabbitmq:publish"] }, { - "id":"integration-dogfood-consolidation-db", + "id":"integration-0000000000000004", "projectPath":"src/Consolidation.Persistence/Consolidation.Persistence.csproj", "kind":"database","direction":"bidirectional","technology":"postgresql", "target":"consolidation_db","resourceType":"database", @@ -46,7 +46,7 @@ "confidence":"high","signals":["database:postgresql"] }, { - "id":"integration-dogfood-rabbit-consume", + "id":"integration-0000000000000005", "projectPath":"src/Consolidation.Worker/Consolidation.Worker.csproj", "kind":"messaging","direction":"consume","technology":"rabbitmq", "target":"consolidation.value-received.v1","resourceType":"queue", From edc126f7a7b4de2e1f9b5953e76ec42f4a1a93d6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:09:03 -0300 Subject: [PATCH 57/74] test: assert canonical semantic C3 selection order (#69) --- tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs | 10 +++++++++- tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs index 753e9501..c75f64f5 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3DogfoodTests.cs @@ -19,6 +19,14 @@ public sealed class SemanticC3DogfoodTests "el_consolidation_worker", ]; + private static readonly string[] CanonicalContainers = + [ + "el_consolidation_api", + "el_consolidation_worker", + "el_ingestion_api", + "el_ingestion_outbox_worker", + ]; + [Fact] public async Task DotNetObservabilityGoldenProducesUsefulBoundedSemanticC3() { @@ -53,7 +61,7 @@ await ExternalIntegrationSnapshotImporter.ImportAsync( Assert.NotNull(build.SemanticResult); Assert.Equal(4, build.Workspace.Selections.Length); - Assert.Equal(SelectedContainers, build.Workspace.Selections.Select(item => item.SelectedContainerId)); + Assert.Equal(CanonicalContainers, build.Workspace.Selections.Select(item => item.SelectedContainerId)); SemanticC3Proposal proposal = build.SemanticResult!.Proposal; AssertResponsibilities( diff --git a/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs b/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs index 125d1f8e..bcc9c9ed 100644 --- a/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs +++ b/tests/Repo2C4.Mcp.Tests/McpSemanticC3DogfoodTests.cs @@ -14,6 +14,14 @@ public sealed class McpSemanticC3DogfoodTests "el_consolidation_worker", ]; + private static readonly string[] CanonicalContainers = + [ + "el_consolidation_api", + "el_consolidation_worker", + "el_ingestion_api", + "el_ingestion_outbox_worker", + ]; + [Fact] public async Task RealInspectionFeedsFourContainerSemanticC3WithoutExposingInternalFacts() { @@ -48,7 +56,7 @@ public async Task RealInspectionFeedsFourContainerSemanticC3WithoutExposingInter Assert.True(generated.DryRun); Assert.False(generated.Written); - Assert.Equal(SelectedContainers, generated.C3Views.Select(view => view.ContainerId)); + Assert.Equal(CanonicalContainers, generated.C3Views.Select(view => view.ContainerId)); Assert.Equal(4, generated.C3Views.Length); McpLikeC4File semanticReport = Assert.Single( From 51cb9fdc946853d35cf7bdaf31d2d50c0706fdd6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:14:45 -0300 Subject: [PATCH 58/74] fix: parse primary-constructor persistence types (#69) --- .../Inspection/SemanticC3FactExtractor.cs | 1 + .../SemanticC3FactExtractorTests.cs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index 38139e15..246cfb44 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -25,6 +25,7 @@ public static class SemanticC3FactExtractor @"[ \t]*(?:(?:public|internal|private|protected|abstract|sealed|static|partial|readonly|ref)\s+)*" + @"(?class|interface|struct|record(?:\s+class|\s+struct)?)\s+" + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:\s*<[^>{;\r\n]+>)?" + + @"(?:\s*\([^()\r\n]{0,1024}\))?" + @"(?:\s*:\s*(?[^\{\r\n]+))?"); private static readonly Regex MethodRegex = CreateRegex( diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index 1f60afc8..65b2d920 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -103,6 +103,32 @@ public interface IOrderConsumer { } }); } + [Fact] + public void ExtractsDbContextWithPrimaryConstructor() + { + using Fixture fixture = new(); + fixture.Add("App.csproj", ""); + fixture.Add("Data.cs", """ + namespace Demo; + + public sealed class DemoDbContext(DbContextOptions options) + : DbContext(options) + { + public static void Save() { } + } + """); + + SemanticC3FactSet result = Extract(fixture); + + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.TypeDeclaration && + fact.SourceSymbol.SymbolId == "T:Demo.DemoDbContext"); + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.PersistenceCandidate && + fact.Category == "semantic.persistence.dbContext" && + fact.SourceSymbol.SymbolId == "T:Demo.DemoDbContext"); + } + [Fact] public void ExtractsMinimalApiLambdaDependenciesAndExplicitHandlers() { From f6157166128c56d39b929aec578ba0d8675993e8 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:17:09 -0300 Subject: [PATCH 59/74] docs: add semantic C3 reference and dogfood result (#69) --- CHANGELOG.md | 1 + docs/semantic-c3-baseline.md | 9 +++ docs/semantic-c3.md | 124 +++++++++++++++++++++++++++++++++++ docs/semantic-c3.pt-BR.md | 124 +++++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 docs/semantic-c3.md create mode 100644 docs/semantic-c3.pt-BR.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1e6963..934661cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Notable changes to Repo2C4 follow [Keep a Changelog](https://keepachangelog.com/ ## [Unreleased] ### Added +- End-to-end Semantic C3 dogfooding derived from `dotnet-observability-lab`, with persisted bounded structural facts, sanitized external-integration correlation, responsibility-level four-container golden coverage, semantic provenance report, managed regeneration, real CLI/MCP paths and official LikeC4 validation while preserving legacy C3 fallback for older snapshots. - Bounded Semantic C3 multi-container support across MCP and Agent: typed `c3Containers` with legacy singular compatibility, deterministic `c3Views` reporting, pre-write ID/budget validation, repeated Agent authorization with evidence-supported subset selection, and exact C3 selection preserved through preview, validation and HITL write approval. - Repeatable CLI `--c3-container` selection backed by one canonical multi-container C3 workspace, deterministic per-container LikeC4 views, duplicate-ID normalization, pre-write validation, and managed-output compatibility while preserving the single-container behavior. - Bounded Semantic C3 internal/external relation composition with explicit wiring+invocation confidence policy, deterministic edge deduplication, weak-cycle/cross-container safeguards, existing C1/C2 peer reuse, and a signal/missing-signal evidence report. diff --git a/docs/semantic-c3-baseline.md b/docs/semantic-c3-baseline.md index 72539719..10755bd6 100644 --- a/docs/semantic-c3-baseline.md +++ b/docs/semantic-c3-baseline.md @@ -59,3 +59,12 @@ boundaries remain reviewable, and no unsupported component is fabricated. This baseline deliberately does not implement semantic detection. It records the starting point so issues #63-#69 can prove material improvement against a stable reference. + + +## Phase 9 result + +Phase 9 now dogfoods a reduced offline golden derived from `rodri-oliveira-dev/dotnet-observability-lab` main commit `66b1dc7c789c96c521d08d7012155cf309f3d3f2`. + +The acceptance path exercises persisted inspection facts, reviewed C2, four selected containers, responsibility-level Semantic C3, internal relations, reviewed PostgreSQL/Redis/RabbitMQ peers, deterministic managed regeneration and official LikeC4 validation. It also asserts that utility/telemetry types are not promoted and that `Consolidation.Worker` does not acquire a Redis edge merely because Redis exists elsewhere in the environment. + +This is materially more informative than the frozen v1.1.0 `HTTP interface / Integration adapter / Application dependency` grouping while remaining bounded and review-first. See [Semantic C3](semantic-c3.md). diff --git a/docs/semantic-c3.md b/docs/semantic-c3.md new file mode 100644 index 00000000..047cd00b --- /dev/null +++ b/docs/semantic-c3.md @@ -0,0 +1,124 @@ +# Semantic C3 + +Semantic C3 turns bounded .NET structural facts into **reviewable architectural component proposals** for explicitly selected C2 containers. It is additive to the stable C1/C2 contract and follows the same evidence-first rule: observed structure is provenance, not architectural truth. + +## Class is not a component + +Repo2C4 never promotes every class to C4. A type becomes a Semantic C3 candidate only when structural evidence supports an architectural responsibility: + +- `httpEndpoint`: Minimal API or controller boundary; +- `applicationService`: application/use-case/handler reached through observable endpoint or consumer wiring/collaboration; +- `backgroundWorker`: `BackgroundService` / `IHostedService`; +- `messagingConsumer` / `messagingPublisher`: local transport-shaped type correlated with normalized integration evidence; +- `persistenceAdapter`: observed `DbContext` or structurally used repository responsibility; +- `integrationAdapter`: supported HTTP/cache/storage integration correlated to one concrete local type. + +Names improve descriptions but never prove a component. Utility, telemetry and unrelated helper types remain outside the C3 graph without stronger evidence. + +## Inspection and persisted facts + +Local `inspect` and MCP `inspect_repository` run the bounded repository inspection plus a bounded C# structural pass. The snapshot may contain additive `semanticC3Facts` with stable symbol identity, type/method/base/interface facts, Minimal API/controller boundaries, DI/constructor wiring, hosted-service, persistence, messaging-role and conservative statically resolvable collaboration facts. + +These records are repository-relative metadata only. They do **not** contain source bodies, arbitrary literals, secret values or absolute paths. + +When a compatible DotNetRepoInspector `InspectionReport` schema 1.6+ is supplied through `--integration-report` / `integrationReportPath`, normalized sanitized findings are also retained as `externalIntegrationEvidence`. Later offline generation can correlate PostgreSQL, Redis, RabbitMQ and other supported peers without re-reading the original report. + +Older snapshots without `semanticC3Facts` remain compatible and use the legacy generic selective-C3 grouping. + +## From reviewed C2 to C3 + +Semantic C3 never chooses C2 container boundaries. The input remains a reviewed C2 `ArchitectureModel`. For each explicitly selected container, Repo2C4 scopes the persisted facts to its evidence-backed projects, proposes responsibility candidates, correlates supported integrations, composes conservative relations, then emits through the normal C3/LikeC4 workspace. + +All selected containers share exactly one reviewed C2 model/snapshot. + +## Confidence and review status + +Component boundaries remain `requiresReview`: static structure can support a useful candidate but cannot prove the only correct architectural decomposition. + +Internal relations are conservative. DI/handler/parameter wiring is one signal; a statically resolved symbol invocation is another. Supported pairs such as HTTP → application, application → persistence, worker → publisher and consumer → application are confirmed only when the required structural signals converge. Names, package presence, `ProjectReference` and isolated type declarations do not confirm runtime flow. Weak reciprocal edges, self-loops and cross-container internal edges are omitted rather than guessed. + +External C3 relations reuse an existing C1/C2 peer and relation evidence. Semantic C3 never invents PostgreSQL, Redis, brokers or HTTP systems from names alone; newly associated external C3 edges remain reviewable. + +## Provenance + +When Semantic C3 is active, generation adds: + +- `c3.views.c4`: one view per selected container; +- `semantic-c3-evidence-report.md`: component/relation IDs, categories, repository-relative locations, signal classes, missing confidence signals, review status and bounded graph diagnostics. + +The regular `evidence-report.md` remains the C1/C2 review artifact. Neither report copies source bodies or configuration values. + +## Single and multi-container + +Single selection remains compatible: + +```bash +repo2c4 generate --model architecture.c2.json --output generated \ + --c3-container el_ingestion_api +``` + +Repeat the option for one workspace with multiple C3 views: + +```bash +repo2c4 generate --model architecture.c2.json --output generated \ + --c3-container el_ingestion_api \ + --c3-container el_ingestion_outbox_worker \ + --c3-container el_consolidation_api \ + --c3-container el_consolidation_worker \ + --apply +``` + +Selections are deduplicated and canonicalized; argument order does not affect output. Unknown/non-container IDs and C3 over C1 fail before managed output is written. + +MCP uses `c3Containers` (legacy `c3ContainerId` remains for one selection). Agent repeated `--c3-container` values are only an authorization allow-list; the Agent must request an evidence-supported subset. + +## Budgets + +| Boundary | Limit | +| --- | ---: | +| C# files | 512 | +| Total accepted C# bytes | 8 MiB | +| Symbols | 4,096 | +| Structural relation facts | 8,192 | +| Persisted semantic facts | 20,000 | +| Semantic diagnostics | 128 | +| Standalone semantic timeout | 5 s | +| Integrated inspection semantic timeout | 10 s | +| Components per selected container | 64 | +| Relations per selected container | 128 | +| MCP C3 selections per call | 8 | +| MCP total components / relations per call | 256 / 512 | +| Agent authorized C3 container IDs | 8 | + +Cancellation is checked throughout scanning/parsing. Partial or malformed source produces controlled diagnostics; Repo2C4 does not build or execute the inspected repository. + +## Dogfooding + +Phase 9 uses a reduced offline golden derived from `dotnet-observability-lab` main commit `66b1dc7c789c96c521d08d7012155cf309f3d3f2`. + +The v1.1.0 baseline for `Ingestion.Api` produced: + +```text +HTTP interface +Integration adapter +Application dependency +``` + +The Semantic C3 golden requires: + +| Container | Architectural responsibilities | +| --- | --- | +| `Ingestion.Api` | HTTP ingestion boundary, application handler/use case, persistence, Redis adapter | +| `Ingestion.Outbox.Worker` | background/outbox poller, RabbitMQ publisher, ingestion persistence | +| `Consolidation.Api` | HTTP read boundary, query/application responsibility, consolidation persistence | +| `Consolidation.Worker` | RabbitMQ consumer, transport-neutral processor, consolidation persistence | + +The golden verifies useful internal relations, reviewed PostgreSQL/Redis/RabbitMQ peers, no Redis edge for `Consolidation.Worker`, no utility/telemetry promotion, four C3 views in one workspace, deterministic managed regeneration and the same semantic path through Core, CLI and real MCP inspection. Agent multi-C3 remains covered by its deterministic real-MCP harness. + +See [the fixture](../examples/fixtures/semantic-c3-dogfood/README.md) and [the v1.1.0 baseline](semantic-c3-baseline.md). + +## Known limitations and security + +Semantic C3 is bounded static analysis, not a universal call graph or runtime tracer. It does not build/execute repository code, evaluate arbitrary MSBuild logic, prove deployment/runtime communication, understand every framework convention, resolve reflection/dynamic calls universally, promote ambiguous type-to-component mappings, or replace architectural review. + +Run inspection against a trusted, stable, preferably read-only checkout. Repository content is untrusted input; path containment, file/symbol/fact budgets, parser timeouts, literal masking and sanitized diagnostics are part of the security boundary. See [CLI](cli.md), [MCP](mcp.md) and [distribution](distribution.md). diff --git a/docs/semantic-c3.pt-BR.md b/docs/semantic-c3.pt-BR.md new file mode 100644 index 00000000..865b224d --- /dev/null +++ b/docs/semantic-c3.pt-BR.md @@ -0,0 +1,124 @@ +# Semantic C3 + +O Semantic C3 transforma fatos estruturais .NET limitados em **propostas revisáveis de componentes arquiteturais** para containers C2 selecionados explicitamente. Ele é aditivo ao contrato C1/C2 estável e mantém a regra evidence-first: estrutura observada é proveniência, não verdade arquitetural. + +## Classe não é componente + +O Repo2C4 nunca promove toda classe para C4. Um tipo só vira candidato Semantic C3 quando evidência estrutural sustenta uma responsabilidade: + +- `httpEndpoint`: fronteira Minimal API ou Controller; +- `applicationService`: application/use case/handler alcançado por wiring/colaboração observável a partir de endpoint ou consumer; +- `backgroundWorker`: `BackgroundService` / `IHostedService`; +- `messagingConsumer` / `messagingPublisher`: tipo local com papel de transporte correlacionado a evidência normalizada de integração; +- `persistenceAdapter`: `DbContext` observado ou repository estruturalmente utilizado; +- `integrationAdapter`: integração HTTP/cache/storage suportada correlacionada a um único tipo concreto local. + +Nomes ajudam na descrição, mas nunca provam um componente. Utilitários, telemetria e helpers não relacionados ficam fora do C3 sem evidência mais forte. + +## Inspeção e fatos persistidos + +`inspect` local e `inspect_repository` MCP executam a inspeção limitada normal e uma passagem estrutural C# limitada. O snapshot pode conter `semanticC3Facts` aditivos com identidade estável de símbolo, fatos de tipo/método/base/interface, fronteiras Minimal API/Controller, DI/injeção, hosted service, persistence, mensageria e colaborações estaticamente resolvíveis de forma conservadora. + +Esses registros contêm somente metadados relativos ao repositório. Eles **não** contêm corpos de source, literais arbitrários, segredos nem caminhos absolutos. + +Com `--integration-report` / `integrationReportPath` e um `InspectionReport` DotNetRepoInspector schema 1.6+ compatível, findings normalizados e sanitizados também são persistidos como `externalIntegrationEvidence`. A geração offline posterior consegue correlacionar PostgreSQL, Redis, RabbitMQ e outros peers suportados sem reler o report original. + +Snapshots antigos sem `semanticC3Facts` continuam compatíveis e usam o agrupamento C3 seletivo genérico legado. + +## Do C2 revisado ao C3 + +Semantic C3 não escolhe fronteiras C2. A entrada continua sendo um `ArchitectureModel` C2 revisado. Para cada container selecionado, o Repo2C4 limita os fatos aos projetos sustentados pelas evidências daquele container, propõe responsabilidades, correlaciona integrações suportadas, compõe relações conservadoras e emite pelo workspace C3/LikeC4 normal. + +Todos os containers selecionados compartilham exatamente o mesmo modelo/snapshot C2 revisado. + +## Confiança e review status + +Fronteiras de componente permanecem `requiresReview`: estrutura estática sustenta um candidato útil, mas não prova a única decomposição correta. + +Relações internas são conservadoras. Wiring por DI/handler/parâmetro é um sinal; invocação de símbolo resolvida estaticamente é outro. Pares suportados como HTTP → application, application → persistence, worker → publisher e consumer → application só são confirmados quando os sinais exigidos convergem. Nome, pacote, `ProjectReference` ou declaração isolada de tipo não confirmam fluxo runtime. Self-loops, ciclos fracos recíprocos e edges internas cruzando containers são omitidos em vez de adivinhados. + +Relações C3 externas reutilizam peer e evidência já existentes em C1/C2. O Semantic C3 não inventa PostgreSQL, Redis, broker ou sistema HTTP pelo nome; novas associações C3 externas continuam revisáveis. + +## Proveniência + +Quando Semantic C3 está ativo, a geração adiciona: + +- `c3.views.c4`: uma view por container; +- `semantic-c3-evidence-report.md`: IDs, categorias, locais relativos, classes de sinal, sinais de confiança ausentes, review status e diagnósticos limitados. + +O `evidence-report.md` normal continua sendo o artefato C1/C2. Nenhum relatório copia corpos de source ou valores de configuração. + +## Single e multi-container + +Uma seleção continua compatível: + +```bash +repo2c4 generate --model architecture.c2.json --output generated \ + --c3-container el_ingestion_api +``` + +Repita a opção para um workspace com várias views C3: + +```bash +repo2c4 generate --model architecture.c2.json --output generated \ + --c3-container el_ingestion_api \ + --c3-container el_ingestion_outbox_worker \ + --c3-container el_consolidation_api \ + --c3-container el_consolidation_worker \ + --apply +``` + +Seleções são deduplicadas e canonizadas; a ordem não altera a saída. IDs desconhecidos/não-container e C3 sobre C1 falham antes da escrita gerenciada. + +MCP usa `c3Containers` (`c3ContainerId` legado permanece para uma seleção). No Agent, `--c3-container` repetido é somente allow-list de autorização; o Agent deve solicitar um subconjunto sustentado por evidência. + +## Budgets + +| Fronteira | Limite | +| --- | ---: | +| Arquivos C# | 512 | +| Bytes C# totais aceitos | 8 MiB | +| Símbolos | 4.096 | +| Fatos estruturais de relação | 8.192 | +| Fatos semânticos persistidos | 20.000 | +| Diagnósticos semânticos | 128 | +| Timeout semântico isolado | 5 s | +| Timeout semântico na inspeção integrada | 10 s | +| Componentes por container selecionado | 64 | +| Relações por container selecionado | 128 | +| Seleções C3 por chamada MCP | 8 | +| Componentes / relações totais por chamada MCP | 256 / 512 | +| IDs C3 autorizados no Agent | 8 | + +Cancelamento é verificado durante scan/parser. Source parcial ou malformado gera diagnóstico controlado; o Repo2C4 não compila nem executa o repositório inspecionado. + +## Dogfooding + +A Fase 9 usa um golden offline reduzido derivado da `main` de `dotnet-observability-lab`, commit `66b1dc7c789c96c521d08d7012155cf309f3d3f2`. + +A baseline v1.1.0 de `Ingestion.Api` produzia: + +```text +HTTP interface +Integration adapter +Application dependency +``` + +O golden Semantic C3 exige: + +| Container | Responsabilidades arquiteturais | +| --- | --- | +| `Ingestion.Api` | fronteira HTTP de ingestão, handler/use case application, persistence, adapter Redis | +| `Ingestion.Outbox.Worker` | worker/poller de Outbox, publisher RabbitMQ, persistence de ingestão | +| `Consolidation.Api` | fronteira HTTP de leitura, query/application, persistence de consolidação | +| `Consolidation.Worker` | consumer RabbitMQ, processor independente de transporte, persistence de consolidação | + +O golden verifica relações internas úteis, peers PostgreSQL/Redis/RabbitMQ revisados, ausência de Redis em `Consolidation.Worker`, ausência de promoção de utilitários/telemetria, quatro views C3 no mesmo workspace, regeneração gerenciada determinística e o mesmo caminho semântico por Core, CLI e inspeção MCP real. O multi-C3 do Agent continua coberto pelo harness determinístico com MCP real. + +Veja a [fixture](../examples/fixtures/semantic-c3-dogfood/README.md) e a [baseline v1.1.0](semantic-c3-baseline.md). + +## Limitações conhecidas e segurança + +Semantic C3 é análise estática limitada, não call graph universal nem tracing runtime. Ele não compila/executa código do repositório, não avalia MSBuild arbitrário, não prova topologia/comunicação runtime, não entende toda convenção de framework, não resolve reflection/dynamic universalmente, não promove mapeamentos ambíguos tipo→componente e não substitui revisão arquitetural. + +Execute a inspeção em checkout confiável, estável e preferencialmente somente leitura. Conteúdo do repositório é entrada não confiável; contenção de caminhos, budgets de arquivos/símbolos/fatos, timeouts de parser, masking de literais e diagnósticos sanitizados fazem parte da fronteira de segurança. Veja [CLI](cli.pt-BR.md), [MCP](mcp.pt-BR.md) e [distribuição](distribution.pt-BR.md). From 8ca73cab2bc2f3e8a53d87d6211a58adbd5ce32b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:18:13 -0300 Subject: [PATCH 60/74] docs: align README and CLI with semantic C3 (#69) --- README.md | 7 ++++--- README.pt-BR.md | 1 + docs/cli.md | 6 +++++- docs/cli.pt-BR.md | 6 +++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bfed7f72..aa455e0f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Repo2C4 keeps AI orchestration outside Core and MCP. Core performs no AI calls, - **Bounded local inspection:** scans one explicitly authorized local root, skips mandatory sensitive/generated paths and linked path escapes, enforces file/byte/entry budgets, and does not execute repository code. - **Traceable .NET evidence:** extracts supported solution/project/source declarations with repository-relative provenance while keeping static declarations and runtime candidates distinct. +- **Semantic C3:** persists bounded structural C# facts and proposes responsibility-level HTTP/application/worker/messaging/persistence/integration components only when structural evidence supports them; class names alone never become components. - **Deterministic LikeC4:** generates reviewable C1/C2 workspaces and validates them with the separately installed official LikeC4 CLI. - **Selective multi-container C3:** expands one or more explicitly selected C2 containers when enough evidence is available; repeated selections share one deterministic workspace and unselected containers are not expanded automatically. - **Review-first writes:** `generate` previews by default. CLI writes require `--apply`; MCP writes require explicit `dryRun=false`, `write=true`, and an authorized relative destination. Managed-file hashes protect human edits. @@ -46,14 +47,14 @@ Repo2C4 currently does **not**: - prove runtime communication, deployment topology, ownership or container boundaries merely from `ProjectReference`, package presence, SDKs or source candidates; - evaluate MSBuild, build or execute the inspected repository, run its hooks/scripts, or read arbitrary source bodies into the public evidence contract; -- turn every .NET project into a C4 container or generate C3 for every container automatically; +- turn every .NET project into a C4 container, turn every class into a C3 component, or generate C3 for every container automatically; - provide its own diagram renderer/editor—the generated workspace is rendered by LikeC4; - support private/authenticated remote Git acquisition, SSH/file URLs, submodules, or analysis that depends on Git LFS; - write generated architecture on inspection or inference alone; - let the MCP server call a hosted AI provider on its own; - remove the need for architectural review. `validate` verifies LikeC4 syntax/workspace integrity, not whether an architectural decision is true. -For the precise security and evidence boundaries, see [contracts](docs/contracts.md), [external integration interoperability](docs/external-integration-boundary.md), [CLI](docs/cli.md), [MCP](docs/mcp.md), and [distribution/security guidance](docs/distribution.md). +For the precise security and evidence boundaries, see [contracts](docs/contracts.md), [Semantic C3](docs/semantic-c3.md), [external integration interoperability](docs/external-integration-boundary.md), [CLI](docs/cli.md), [MCP](docs/mcp.md), and [distribution/security guidance](docs/distribution.md). ## Quick Start @@ -85,7 +86,7 @@ repo2c4 inspect \ The resulting snapshot is deterministic for this fixture and can be compared with [`examples/end-to-end/snapshot.v1.json`](examples/end-to-end/snapshot.v1.json). -For external integrations, first run `dotnet repo-inspect /absolute/repository --discover-integrations --output /absolute/repository/artifacts/inspection.json` with DotNetRepoInspector `v1.6.5`, then add `--integration-report /absolute/repository/artifacts/inspection.json` to `repo2c4 inspect`. Repo2C4 imports normalized `external.*` evidence through the public schema `1.6+` JSON boundary; it neither invokes DotNetRepoInspector nor reads its internal assemblies. See the [external integration boundary](docs/external-integration-boundary.md). +For external integrations, first run `dotnet repo-inspect /absolute/repository --discover-integrations --output /absolute/repository/artifacts/inspection.json` with DotNetRepoInspector `v1.6.5`, then add `--integration-report /absolute/repository/artifacts/inspection.json` to `repo2c4 inspect`. Repo2C4 imports normalized `external.*` evidence through the public schema `1.6+` JSON boundary; it neither invokes DotNetRepoInspector nor reads its internal assemblies. Local inspection also persists bounded metadata-only `semanticC3Facts` for later selective C3 generation. See [Semantic C3](docs/semantic-c3.md) and the [external integration boundary](docs/external-integration-boundary.md). ### 3. Review the model and preview LikeC4 diff --git a/README.pt-BR.md b/README.pt-BR.md index 9824a9f4..cda27a38 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -31,6 +31,7 @@ O Repo2C4 mantém a orquestração de IA fora do Core e do MCP. O Core não faz - **Inspeção local limitada:** examina uma única raiz local explicitamente autorizada, ignora caminhos sensíveis/gerados obrigatórios e escapes por links, aplica budgets de arquivos/bytes/entradas e não executa código do repositório. - **Evidências .NET rastreáveis:** extrai declarações suportadas de solution/project/source com proveniência relativa ao repositório, mantendo declarações estáticas e candidatos de runtime semanticamente separados. +- **Semantic C3:** persiste fatos estruturais C# limitados e propõe componentes por responsabilidade HTTP/application/worker/mensageria/persistence/integração somente quando há suporte estrutural; nome de classe sozinho nunca vira componente. - **LikeC4 determinístico:** gera workspaces C1/C2 revisáveis e os valida com a CLI oficial do LikeC4 instalada separadamente. - **C3 seletivo multi-container:** expande um ou mais containers C2 selecionados explicitamente quando há evidência suficiente; seleções repetidas compartilham um único workspace determinístico e containers não selecionados não recebem C3 automaticamente. - **Escrita review-first:** `generate` é preview por padrão. Na CLI, escrita exige `--apply`; no MCP exige `dryRun=false`, `write=true` e um destino relativo autorizado. Hashes dos arquivos gerenciados protegem edições humanas. diff --git a/docs/cli.md b/docs/cli.md index 9ac9efc1..00fd1122 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -42,13 +42,15 @@ repo2c4 generate --model architecture.json --output DIR The input must be a valid v1 `ArchitectureModel` that was proposed or reviewed by a human. The command does not infer a C4 model from the snapshot. -Four files are produced inside the selected directory: +Base C1/C2 generation produces: - `specification.c4` - `model.c4` - `views.c4` - `evidence-report.md` +When C3 is selected, `c3.views.c4` is added. When the model snapshot contains persisted Semantic C3 facts, generation also adds `semantic-c3-evidence-report.md`. + Generation is preview-only by default. Repo2C4 reports `added`, `modified`, `unchanged` or `conflict` for each managed output and does not modify the filesystem. To apply a reviewed preview: @@ -71,6 +73,8 @@ repo2c4 generate \ --c3-container el_worker ``` +When the inspected snapshot contains `semanticC3Facts`, selected containers use the Semantic C3 classifiers rather than the legacy generic grouping. Class names alone are not promoted: HTTP/application/worker/messaging/persistence/integration candidates require structural provenance. Older snapshots without semantic facts retain the generic C3 fallback. See [Semantic C3](semantic-c3.md). + A single `--c3-container` remains backward compatible with the previous behavior. With multiple selections, Repo2C4 builds one canonical workspace over the same C2 base model/snapshot, nests each component set under its selected container in one `model.c4`, and writes one `c3.views.c4` containing one deterministic C3 view per selected container. Argument order does not affect output and repeated container IDs are deduplicated. Without this option no C3 file is produced. Unknown IDs, actors/software systems passed as C3 selections, and C3 requested for a C1 model fail as invalid data before managed output is previewed or written. A selected container with insufficient C3 evidence also fails rather than fabricating components. diff --git a/docs/cli.pt-BR.md b/docs/cli.pt-BR.md index ee54c9b6..b05a5217 100644 --- a/docs/cli.pt-BR.md +++ b/docs/cli.pt-BR.md @@ -42,13 +42,15 @@ repo2c4 generate --model architecture.json --output DIR A entrada deve ser um `ArchitectureModel` v1 válido, proposto ou revisado por uma pessoa. O comando não infere um modelo C4 a partir do snapshot. -São produzidos quatro arquivos dentro do diretório escolhido: +A geração base C1/C2 produz: - `specification.c4` - `model.c4` - `views.c4` - `evidence-report.md` +Quando C3 é selecionado, `c3.views.c4` é adicionado. Quando o snapshot do modelo contém fatos Semantic C3 persistidos, a geração também adiciona `semantic-c3-evidence-report.md`. + A geração é somente preview por padrão. O Repo2C4 informa `added`, `modified`, `unchanged` ou `conflict` para cada saída gerenciada e não altera o filesystem. Para aplicar um preview já revisado: @@ -71,6 +73,8 @@ repo2c4 generate \ --c3-container el_worker ``` +Quando o snapshot inspecionado contém `semanticC3Facts`, os containers selecionados usam os classificadores Semantic C3 em vez do agrupamento genérico legado. Nome de classe sozinho não é promovido: candidatos HTTP/application/worker/mensageria/persistence/integração exigem proveniência estrutural. Snapshots antigos sem fatos semânticos mantêm o fallback C3 genérico. Veja [Semantic C3](semantic-c3.pt-BR.md). + Uma única ocorrência de `--c3-container` permanece compatível com o comportamento anterior. Com múltiplas seleções, o Repo2C4 constrói um único workspace canônico sobre o mesmo modelo/snapshot C2, aninha cada conjunto de componentes no seu container em um único `model.c4` e grava um único `c3.views.c4` com uma view C3 determinística por container selecionado. A ordem dos argumentos não altera a saída e IDs repetidos são deduplicados. Sem essa opção nenhum arquivo C3 é gerado. IDs desconhecidos, actors/software systems usados como seleção C3 e C3 solicitado sobre um modelo C1 falham como dados inválidos antes do preview ou da escrita gerenciada. Um container selecionado sem evidência C3 suficiente também falha em vez de fabricar componentes. From fd4e6d4a7275b027b5d5417c01969763a9432243 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:19:10 -0300 Subject: [PATCH 61/74] docs: document semantic C3 MCP boundary (#69) --- docs/mcp-quickstart.md | 2 ++ docs/mcp-quickstart.pt-BR.md | 2 ++ docs/mcp.md | 2 ++ docs/mcp.pt-BR.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/docs/mcp-quickstart.md b/docs/mcp-quickstart.md index 09118ef2..ca97c18b 100644 --- a/docs/mcp-quickstart.md +++ b/docs/mcp-quickstart.md @@ -61,6 +61,8 @@ After the client connects, inspect the authorized root itself: For selective C3, new clients should pass only the evidence-supported C2 container IDs in `c3Containers` to both proposal generation and proposal validation, for example `["container_api", "container_worker"]`. The collection is bounded to 8 selections and duplicates are deduplicated deterministically. Legacy `c3ContainerId` remains supported for one container. +Clients do not reconstruct or submit internal Semantic C3 facts. The server retains the canonical bounded facts produced by inspection and uses them only after the submitted public snapshot passes freshness validation. When active, Semantic C3 responses include generated `c3Views` and `semantic-c3-evidence-report.md`. See [Semantic C3](semantic-c3.md). + The host selects the AI model. Repo2C4 MCP has no cloud-provider key and does not promote hypotheses to facts. ## Reproducible smoke diff --git a/docs/mcp-quickstart.pt-BR.md b/docs/mcp-quickstart.pt-BR.md index 895c4dd9..680eac96 100644 --- a/docs/mcp-quickstart.pt-BR.md +++ b/docs/mcp-quickstart.pt-BR.md @@ -63,6 +63,8 @@ As evidências importadas usam categorias `external.*`, entram no mesmo snapshot Para C3 seletivo, clientes novos devem passar somente os IDs de containers C2 sustentados por evidência em `c3Containers` tanto na geração quanto na validação da proposta, por exemplo `["container_api", "container_worker"]`. A coleção é limitada a 8 seleções e duplicatas são deduplicadas deterministicamente. O `c3ContainerId` legado continua suportado para um único container. +Clientes não reconstroem nem enviam os fatos Semantic C3 internos. O servidor mantém os fatos canônicos e limitados produzidos pela inspeção e só os utiliza após o snapshot público enviado passar pela validação de freshness. Quando ativo, Semantic C3 inclui as `c3Views` geradas e `semantic-c3-evidence-report.md`. Veja [Semantic C3](semantic-c3.pt-BR.md). + O host escolhe o modelo de IA. O MCP Repo2C4 não possui chave de provedor cloud e não transforma hipótese em fato. ## Smoke reproduzível diff --git a/docs/mcp.md b/docs/mcp.md index db3442d9..22dad298 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -84,6 +84,8 @@ The normal local server exposes six tools: | `generate_likec4` | Generate deterministic C1/C2 LikeC4 and optional selective multi-container C3. | `snapshotId`, complete model, optional `destinationPath`, preferred optional `c3Containers`, legacy optional `c3ContainerId`. | Preview by default. Explicit flags are required to write. | | `validate_likec4` | Validate a proposed model or existing generated workspace through the controlled official LikeC4 CLI adapter. | `snapshotId`, complete model, optional `destinationPath`, preferred optional `c3Containers`, legacy optional `c3ContainerId`. | Read-only. | +Local `inspect_repository` also computes bounded Semantic C3 structural facts. Those internal facts stay in the session snapshot for C3 generation and are not exposed through a generic source-data tool. The client still submits the public reviewed C1/C2 model; after public-snapshot freshness validation, the server binds it to the canonical inspected facts before Semantic C3 generation. This prevents a client/model from injecting fabricated structural facts. Semantic C3 generation reports exact `c3Views` and includes `semantic-c3-evidence-report.md`. See [Semantic C3](semantic-c3.md). + `inspect_remote_repository` is **not** exposed by default. It appears only when the host explicitly starts the server with `--allow-remote-acquisition`. ## First local inspection diff --git a/docs/mcp.pt-BR.md b/docs/mcp.pt-BR.md index ad160636..0ce2b690 100644 --- a/docs/mcp.pt-BR.md +++ b/docs/mcp.pt-BR.md @@ -84,6 +84,8 @@ O servidor local normal expõe seis tools: | `generate_likec4` | Gera LikeC4 C1/C2 determinístico e C3 seletivo multi-container opcional. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3Containers` opcional preferencial, `c3ContainerId` opcional legado. | Preview por padrão. Flags explícitas são obrigatórias para escrever. | | `validate_likec4` | Valida modelo proposto ou workspace gerado existente pelo adaptador controlado da CLI oficial LikeC4. | `snapshotId`, modelo completo, `destinationPath` opcional, `c3Containers` opcional preferencial, `c3ContainerId` opcional legado. | Somente leitura. | +O `inspect_repository` local também calcula fatos estruturais Semantic C3 limitados. Esses fatos internos ficam no snapshot da sessão para geração C3 e não são expostos por uma tool genérica de source. O cliente continua enviando o modelo C1/C2 público revisado; depois da validação de freshness do snapshot público, o servidor o vincula aos fatos canônicos inspecionados antes da geração Semantic C3. Isso impede que cliente/modelo injete fatos estruturais fabricados. A geração Semantic C3 informa exatamente as `c3Views` e inclui `semantic-c3-evidence-report.md`. Veja [Semantic C3](semantic-c3.pt-BR.md). + `inspect_remote_repository` **não** é exposta por padrão. Ela aparece somente quando o host inicia explicitamente o servidor com `--allow-remote-acquisition`. ## Primeira inspeção local From 363873362d8e0c2078a7a49f4ea5a85e9c39504e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:19:51 -0300 Subject: [PATCH 62/74] docs: align Agent and distribution with semantic C3 (#69) --- docs/agent.md | 2 ++ docs/agent.pt-BR.md | 2 ++ docs/distribution.md | 2 +- docs/distribution.pt-BR.md | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/agent.md b/docs/agent.md index 077ec615..1d6222da 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -210,6 +210,8 @@ Up to 8 container IDs may be authorized. These values are an allow-list, not an A single `--c3-container` preserves the prior behavior. For multi-container proposals, the exact selected IDs are carried through dry-run preview, validation, correction attempts and the destination-specific HITL write plan. The approval prompt shows the selected C3 containers before any protected write. +The Agent never receives or authors the internal Semantic C3 structural fact set. It reasons over bounded MCP evidence and a reviewed C2 proposal; MCP privately binds that proposal to canonical inspected facts before generation. Repository text or model output therefore cannot escalate C3 by fabricating structural facts. See [Semantic C3](semantic-c3.md). + ## Human approval and writing Without `--write-destination`, the Agent never asks to write. diff --git a/docs/agent.pt-BR.md b/docs/agent.pt-BR.md index 3a7c3659..7aaeab68 100644 --- a/docs/agent.pt-BR.md +++ b/docs/agent.pt-BR.md @@ -199,6 +199,8 @@ Podem ser autorizados no máximo 8 IDs. Esses valores formam uma allow-list; ele Uma única ocorrência de `--c3-container` preserva o comportamento anterior. Em propostas multi-container, os IDs efetivamente selecionados são preservados no dry-run, validação, tentativas de correção e plano HITL específico do destino. O prompt de aprovação mostra os containers C3 selecionados antes de qualquer escrita protegida. +O Agent nunca recebe nem cria o conjunto interno de fatos estruturais Semantic C3. Ele raciocina sobre evidências MCP limitadas e uma proposta C2 revisada; o MCP vincula privadamente essa proposta aos fatos canônicos inspecionados antes da geração. Texto do repositório ou saída do modelo não consegue escalar C3 fabricando fatos estruturais. Veja [Semantic C3](semantic-c3.pt-BR.md). + ## Aprovação humana e escrita Sem `--write-destination`, o Agent não pede escrita. diff --git a/docs/distribution.md b/docs/distribution.md index 6a60f3f0..0e2099be 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -109,7 +109,7 @@ repo2c4 generate --model architecture.reviewed.json --output ./likec4 --apply repo2c4 validate --output ./likec4 ``` -Generation previews by default, `--apply` writes only controlled output and does not overwrite manually edited files. Review `likec4/evidence-report.md` for hypotheses/provenance. C1/C2 are supported by default; generate **C3 for one selected existing C2 container only** with `--c3-container CONTAINER_ID` using a previously reviewed C2 model (see [selective C3 example](../examples/end-to-end/README.md)). A library-only inventory is not proof of a running container. LikeC4 must be installed for `validate`, and its DSL checks are not a substitute for review of architecture claims. +Generation previews by default, `--apply` writes only controlled output and does not overwrite manually edited files. Review `likec4/evidence-report.md` for C1/C2 hypotheses/provenance. Repeat `--c3-container CONTAINER_ID` to select one or more reviewed C2 containers in the same workspace; when inspection persisted Semantic C3 facts, generation also writes `semantic-c3-evidence-report.md`. Class names alone do not become components, while older snapshots retain the generic C3 fallback. See [Semantic C3](semantic-c3.md) and the [selective C3 example](../examples/end-to-end/README.md). A library-only inventory is not proof of a running container. LikeC4 must be installed for `validate`, and its DSL checks are not a substitute for review of architecture claims. ## MCP client configuration and local filesystem policy diff --git a/docs/distribution.pt-BR.md b/docs/distribution.pt-BR.md index c8c7a8fb..c4fc35a5 100644 --- a/docs/distribution.pt-BR.md +++ b/docs/distribution.pt-BR.md @@ -104,7 +104,7 @@ repo2c4 generate --model architecture.reviewed.json --output ./likec4 --apply repo2c4 validate --output ./likec4 ``` -O primeiro `generate` é somente preview. A escrita exige `--apply` e respeita o manifesto de arquivos gerenciados; edições manuais não são sobrescritas. Examine `likec4/evidence-report.md`. O C1/C2 é o fluxo padrão. Para expandir somente **um container C2 existente**, utilize `--c3-container ID_DO_CONTAINER` com modelo C2 já revisado ([exemplo C3](../examples/end-to-end/README.md)). Uma biblioteca .NET isolada não implica um container em execução. O `validate` verifica o DSL LikeC4 instalado, não a veracidade das decisões arquiteturais. +O primeiro `generate` é somente preview. A escrita exige `--apply` e respeita o manifesto de arquivos gerenciados; edições manuais não são sobrescritas. Examine `likec4/evidence-report.md` para C1/C2. Repita `--c3-container ID_DO_CONTAINER` para selecionar um ou mais containers C2 revisados no mesmo workspace; quando a inspeção persistiu fatos Semantic C3, a geração também grava `semantic-c3-evidence-report.md`. Nome de classe sozinho não vira componente e snapshots antigos mantêm o fallback C3 genérico. Veja [Semantic C3](semantic-c3.pt-BR.md) e o [exemplo C3](../examples/end-to-end/README.md). Uma biblioteca .NET isolada não implica um container em execução. O `validate` verifica o DSL LikeC4 instalado, não a veracidade das decisões arquiteturais. ## Instalação MCP e acesso ao sistema de arquivos From 4f4420e779b03f84c90b32516bdae2f012c3ad53 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:20:38 -0300 Subject: [PATCH 63/74] docs: connect semantic C3 mapping and provenance (#69) --- docs/c4-mapping.md | 9 +++++++++ docs/evidence-report.md | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/c4-mapping.md b/docs/c4-mapping.md index 33f8f6de..5803f3b4 100644 --- a/docs/c4-mapping.md +++ b/docs/c4-mapping.md @@ -175,3 +175,12 @@ The Semantic C3 evidence report lists each candidate/relation, its evidence loca ## Out of scope The C1/C2 mapping policy itself does not implement LikeC4 emission, CLI/MCP/Agent orchestration, AI calls, runtime tracing or automatic PR generation. The additive Semantic C3 policies above only define conservative component proposals; generation/orchestration is handled by later phases. + + +## Integrated Semantic C3 generation + +The Semantic C3 policies above are connected to the normal inspection/generation path. Local inspection persists bounded `semanticC3Facts`; an optional compatible integration report persists normalized `externalIntegrationEvidence`. A reviewed C2 model can then select one or more containers and reuse those persisted facts offline. + +The stable rule remains **class != component**. Responsibility candidates require architectural signals, and relations use the dedicated wiring/invocation confidence policy. Older v1 snapshots without semantic facts continue through the legacy generic selective-C3 grouping. + +See [Semantic C3](semantic-c3.md) for the end-to-end contract, budgets, dogfooding result and known limitations. diff --git a/docs/evidence-report.md b/docs/evidence-report.md index 01e03d23..fe4da90e 100644 --- a/docs/evidence-report.md +++ b/docs/evidence-report.md @@ -29,3 +29,14 @@ Repository-static or candidate signals are not upgraded to confirmed runtime rel ## MCP The read-only `get_evidence_report` tool accepts a session `snapshotId` and complete v1 model. The server verifies that the embedded snapshot matches the session snapshot and returns a bounded summary of the report: counts, review-required assertion IDs and warning codes. The Markdown body remains a CLI artifact; MCP does not return source bodies, configuration values, absolute paths, or write to the repository. + + +## Semantic C3 evidence report + +When C3 is requested from a snapshot containing `semanticC3Facts`, generation additionally produces `semantic-c3-evidence-report.md`. It is separate from the C1/C2 report because C3 component provenance includes stable source-symbol facts as well as normal architecture evidence. + +The Semantic C3 report lists component/relation IDs, responsibility categories, repository-relative locations, structural signal classes, missing confidence signals, review status and bounded graph diagnostics. It never includes source bodies, arbitrary literals, configuration values or absolute paths. + +A `confirmed` internal C3 relation means the configured static confidence rule was satisfied (for example supported wiring plus a statically resolved collaboration). It is not runtime tracing. Component boundaries remain reviewable. + +See [Semantic C3](semantic-c3.md). From 1a65c721c8e6b6c8886b501559045b54fc9305b0 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:21:43 -0300 Subject: [PATCH 64/74] docs: add Portuguese mapping and evidence guides (#69) --- docs/c4-mapping.md | 2 ++ docs/c4-mapping.pt-BR.md | 38 ++++++++++++++++++++++++++++++ docs/evidence-report.md | 2 ++ docs/evidence-report.pt-BR.md | 44 +++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 docs/c4-mapping.pt-BR.md create mode 100644 docs/evidence-report.pt-BR.md diff --git a/docs/c4-mapping.md b/docs/c4-mapping.md index 5803f3b4..a26738ea 100644 --- a/docs/c4-mapping.md +++ b/docs/c4-mapping.md @@ -1,3 +1,5 @@ +[Português (Brasil)](c4-mapping.pt-BR.md) + # C1/C2 mapping policy Repo2C4 maps repository evidence into a reviewable `ArchitectureModel` without treating repository structure as deployed architecture. The evidence contract in `docs/contracts.md` remains the source of truth. These rules define how evidence may be proposed for C1/C2; they do not add a second architecture model and they do not authorize automatic promotion of hypotheses to confirmed facts. diff --git a/docs/c4-mapping.pt-BR.md b/docs/c4-mapping.pt-BR.md new file mode 100644 index 00000000..cdf8f8aa --- /dev/null +++ b/docs/c4-mapping.pt-BR.md @@ -0,0 +1,38 @@ +# Política de mapeamento C4 + +[English](c4-mapping.md) + +Este guia resume as regras de mapeamento evidence-first do Repo2C4 para C1, C2 e Semantic C3. A regra principal é separar **observação de repositório** de **decisão arquitetural**. + +## C1/C2 + +- projeto .NET não implica automaticamente container; +- `ProjectReference`, package, SDK ou source candidate não confirma comunicação runtime; +- container/deployment boundary permanece `requiresReview` quando sustentado apenas por sinais estáticos; +- relações precisam apontar para elementos existentes e manter proveniência; +- modelos parciais são preferíveis a completar a arquitetura por suposição. + +A ausência de evidência não autoriza criar actor, sistema externo, container ou relação. Hipóteses úteis devem permanecer explícitas em `reviewReason`. + +## Semantic C3 + +Semantic C3 é aditivo ao C1/C2 revisado. **Classe não é componente**. + +As responsabilidades suportadas incluem fronteira HTTP, application/use case, worker, publisher/consumer, persistence e integration adapter. Uma classe só é promovida quando sinais estruturais sustentam aquela responsabilidade. Nome, package ou referência de projeto isolados não bastam. + +Para relações internas: + +- wiring DI/handler/parâmetro é um sinal; +- invocação de símbolo resolvida estaticamente é outro; +- pares suportados só podem ser confirmados quando os sinais exigidos convergem; +- relações fracas, ciclos ambíguos, self-loops e edges internas cruzando containers são omitidos em vez de inventados. + +Para relações externas, C3 reutiliza peer e evidência de relação já presentes no modelo C1/C2. PostgreSQL, Redis, broker e APIs externas não são criados apenas pelo nome de um tipo. + +## Pipeline integrado + +A inspeção local persiste `semanticC3Facts` limitados. Um report DotNetRepoInspector compatível pode adicionar `externalIntegrationEvidence` sanitizado. Depois, um modelo C2 revisado seleciona um ou mais containers e a geração reutiliza esses fatos offline. + +Snapshots antigos sem fatos semânticos continuam no agrupamento C3 genérico compatível. + +Veja [Semantic C3](semantic-c3.pt-BR.md) para categorias, budgets, dogfooding e limitações, e [relatório de evidências](evidence-report.pt-BR.md) para provenance. diff --git a/docs/evidence-report.md b/docs/evidence-report.md index fe4da90e..a4496594 100644 --- a/docs/evidence-report.md +++ b/docs/evidence-report.md @@ -1,3 +1,5 @@ +[Português (Brasil)](evidence-report.pt-BR.md) + # Evidence report and architectural review Issue #17 adds a deterministic `evidence-report.md` generated from a reviewed v1 `ArchitectureModel`. diff --git a/docs/evidence-report.pt-BR.md b/docs/evidence-report.pt-BR.md new file mode 100644 index 00000000..896bed80 --- /dev/null +++ b/docs/evidence-report.pt-BR.md @@ -0,0 +1,44 @@ +# Relatório de evidências e revisão arquitetural + +[English](evidence-report.md) + +O Repo2C4 mantém relatórios determinísticos e somente de metadados. Eles podem conter IDs arquiteturais, IDs de evidência, caminhos relativos ao repositório e linhas, mas não copiam corpos de source, valores de configuração, tokens, segredos ou caminhos absolutos. + +## Relatório C1/C2 + +`evidence-report.md` é gerado a partir do `ArchitectureModel` revisado e ajuda a revisar: + +- afirmações e IDs de evidência; +- hipóteses `requiresReview`; +- warnings/diagnósticos de scan; +- evidências ausentes ou origens não resolvidas. + +Fluxo recomendado: + +1. inspecione e obtenha o snapshot; +2. construa/revise o modelo; +3. gere preview; +4. revise `requiresReview` e siga a proveniência até source/caminho relativo; +5. confirme somente quando houver suporte adequado; +6. regenere e valide LikeC4. + +Sinal estático não vira runtime edge confirmado só porque aparece no relatório. + +## Relatório Semantic C3 + +Quando C3 é solicitado a partir de snapshot com `semanticC3Facts`, a geração adiciona `semantic-c3-evidence-report.md`. + +Ele lista: + +- IDs e categorias de componentes/relações; +- localizações relativas; +- classes de sinais estruturais; +- sinais de confiança ausentes; +- review status; +- diagnósticos limitados do grafo. + +Uma relação interna C3 `confirmed` significa que a política estática configurada foi satisfeita (por exemplo wiring suportado + colaboração resolvida). Não significa tracing runtime. Fronteiras de componentes continuam revisáveis. + +No MCP, `get_evidence_report` continua sendo o resumo C1/C2 vinculado ao snapshot da sessão; o relatório semântico faz parte dos arquivos retornados por `generate_likec4` quando Semantic C3 está ativo. + +Veja [Semantic C3](semantic-c3.pt-BR.md). From e75ba0c2c1fec68e55ee4aafdb26ed4dcbe9274d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:27:45 -0300 Subject: [PATCH 65/74] fix: normalize primary-constructor base invocations (#69) --- .../Inspection/SemanticC3FactExtractor.cs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index 246cfb44..020eaa5f 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -938,15 +938,44 @@ private static ParameterDeclaration[] ParseParameters(string value) private static string[] SplitTypeList(string value) => [.. SplitCommaSeparated(value) - .Select(item => NormalizeTypeToken(item)) + .Select(NormalizeBaseTypeToken) .Where(item => item.Length > 0) .Take(MaxBaseTypesPerType)]; + private static string NormalizeBaseTypeToken(string value) + { + int angle = 0; + int square = 0; + for (int index = 0; index < value.Length; index++) + { + switch (value[index]) + { + case '<': + angle++; + break; + case '>': + angle = Math.Max(0, angle - 1); + break; + case '[': + square++; + break; + case ']': + square = Math.Max(0, square - 1); + break; + case '(' when angle == 0 && square == 0: + return NormalizeTypeToken(value[..index]); + } + } + + return NormalizeTypeToken(value); + } + private static IEnumerable SplitCommaSeparated(string value) { int start = 0; int angle = 0; int square = 0; + int round = 0; for (int i = 0; i < value.Length; i++) { @@ -964,7 +993,13 @@ private static IEnumerable SplitCommaSeparated(string value) case ']': square = Math.Max(0, square - 1); break; - case ',' when angle == 0 && square == 0: + case '(': + round++; + break; + case ')': + round = Math.Max(0, round - 1); + break; + case ',' when angle == 0 && square == 0 && round == 0: yield return value[start..i]; start = i + 1; break; From 78ec59ee0c6fb7fd24f1e8ef79bcaaf38af9f272 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 06:57:24 -0300 Subject: [PATCH 66/74] fix: extract primary-constructor dependencies for semantic C3 (#69) --- .../Inspection/SemanticC3FactExtractor.cs | 36 ++++++++++++++++--- .../SemanticC3FactExtractorTests.cs | 6 ++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index 020eaa5f..d84abf1a 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -25,7 +25,7 @@ public static class SemanticC3FactExtractor @"[ \t]*(?:(?:public|internal|private|protected|abstract|sealed|static|partial|readonly|ref)\s+)*" + @"(?class|interface|struct|record(?:\s+class|\s+struct)?)\s+" + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:\s*<[^>{;\r\n]+>)?" + - @"(?:\s*\([^()\r\n]{0,1024}\))?" + + @"(?:\s*\((?[^()\r\n]{0,1024})\))?" + @"(?:\s*:\s*(?[^\{\r\n]+))?"); private static readonly Regex MethodRegex = CreateRegex( @@ -712,14 +712,40 @@ private static SourceUnit ParseUnit( attributes, []); + List methods = []; + if (match.Groups["primaryParams"].Success && + !type.IsInterface) + { + ParameterDeclaration[] primaryParameters = + ParseParameters(match.Groups["primaryParams"].Value); + methods.Add(new MethodDeclaration( + type.Name, + MethodSymbolId(type, "#ctor", primaryParameters, isConstructor: true), + true, + type.Line, + [], + primaryParameters, + -1, + -1, + match.Index)); + } + if (bodyStart >= 0) { - type = type with - { - Methods = ParseMethods(masked, braceDepth, lineStarts, type), - }; + methods.AddRange(ParseMethods(masked, braceDepth, lineStarts, type)); } + type = type with + { + Methods = + [ + .. methods + .GroupBy(method => method.SymbolId, StringComparer.Ordinal) + .Select(group => group.OrderBy(method => method.StartIndex).First()) + .OrderBy(method => method.StartIndex), + ], + }; + types.Add(type); } diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index 65b2d920..47fa89b7 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -127,6 +127,12 @@ public static void Save() { } fact.Kind == SemanticC3FactKind.PersistenceCandidate && fact.Category == "semantic.persistence.dbContext" && fact.SourceSymbol.SymbolId == "T:Demo.DemoDbContext"); + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.ConstructorInjection && + fact.SourceSymbol.SymbolId.StartsWith( + "M:Demo.DemoDbContext.#ctor(", + StringComparison.Ordinal) && + fact.RelatedSymbolId == "T:DbContextOptions:0"); } [Fact] From 6052e804de50a27a77408c073debb87c493ad931 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:02:00 -0300 Subject: [PATCH 67/74] fix: support bounded multiline primary constructors (#69) --- .../Inspection/SemanticC3FactExtractor.cs | 2 +- .../SemanticC3FactExtractorTests.cs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index d84abf1a..d36a5152 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -25,7 +25,7 @@ public static class SemanticC3FactExtractor @"[ \t]*(?:(?:public|internal|private|protected|abstract|sealed|static|partial|readonly|ref)\s+)*" + @"(?class|interface|struct|record(?:\s+class|\s+struct)?)\s+" + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:\s*<[^>{;\r\n]+>)?" + - @"(?:\s*\((?[^()\r\n]{0,1024})\))?" + + @"(?:\s*\((?[^(){};]{0,1024})\))?" + @"(?:\s*:\s*(?[^\{\r\n]+))?"); private static readonly Regex MethodRegex = CreateRegex( diff --git a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs index 47fa89b7..fa251b14 100644 --- a/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/SemanticC3FactExtractorTests.cs @@ -111,11 +111,17 @@ public void ExtractsDbContextWithPrimaryConstructor() fixture.Add("Data.cs", """ namespace Demo; - public sealed class DemoDbContext(DbContextOptions options) + public sealed class DemoDbContext( + DbContextOptions options, + IClock clock) : DbContext(options) { public static void Save() { } } + + public interface IClock + { + } """); SemanticC3FactSet result = Extract(fixture); @@ -133,6 +139,12 @@ public static void Save() { } "M:Demo.DemoDbContext.#ctor(", StringComparison.Ordinal) && fact.RelatedSymbolId == "T:DbContextOptions:0"); + Assert.Contains(result.Facts, fact => + fact.Kind == SemanticC3FactKind.ConstructorInjection && + fact.SourceSymbol.SymbolId.StartsWith( + "M:Demo.DemoDbContext.#ctor(", + StringComparison.Ordinal) && + fact.RelatedSymbolId == "T:IClock:1"); } [Fact] From 9b7498cfcc3889f4be23bf48db13e6835a7ef7b4 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:11:11 -0300 Subject: [PATCH 68/74] fix: preserve legacy snapshot surface without semantic facts (#69) --- .../Inspection/RepositoryFactExtractor.cs | 8 ++++++-- .../RepositoryFactExtractorTests.cs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs b/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs index 07d9c0fd..6a729302 100644 --- a/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/RepositoryFactExtractor.cs @@ -125,19 +125,23 @@ public static RepositorySnapshot Extract( }, cancellationToken); + SemanticC3FactSet? persistedSemanticFacts = + semanticFacts.Facts.IsEmpty && semanticFacts.Diagnostics.IsEmpty + ? null + : semanticFacts; + RepositorySnapshot result = snapshot with { Evidence = [.. evidence.Items.OrderBy(item => item.Id, StringComparer.Ordinal)], Diagnostics = [ .. diagnostics - .Concat(semanticFacts.Diagnostics) .Distinct() .OrderBy(item => item.Code, StringComparer.Ordinal) .ThenBy(item => item.RelativePath, StringComparer.Ordinal) .ThenBy(item => item.Message, StringComparer.Ordinal), ], - SemanticC3Facts = semanticFacts, + SemanticC3Facts = persistedSemanticFacts, }; ImmutableArray errors = ContractValidator.ValidateSnapshot(result); if (!errors.IsEmpty) diff --git a/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs b/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs index be110f1b..617b2df8 100644 --- a/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs @@ -6,6 +6,24 @@ namespace Repo2C4.Core.Tests; public sealed class RepositoryFactExtractorTests { + [Fact] + public void ProjectOnlyInspectionPreservesLegacySnapshotSurface() + { + using Fixture fixture = new(); + fixture.Add("OnlyLib.csproj", "net10.0"); + + RepositoryScanOptions options = new(fixture.Root, "legacy_snapshot_surface"); + RepositorySnapshot result = RepositoryFactExtractor.Extract(options); + + Assert.Null(result.SemanticC3Facts); + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Code.StartsWith("semanticC3.", StringComparison.Ordinal)); + + string json = ContractJson.SerializeSnapshot(result); + Assert.DoesNotContain("semanticC3Facts", json, StringComparison.Ordinal); + } + [Fact] public void MultiProjectFixturePreservesProvenanceAndNeverPromotesCandidateToRuntimeRelation() { From ce1ef53ee427c133b9c9cea75a65d8ee90707a28 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:12:58 -0300 Subject: [PATCH 69/74] test: honor cancellation in legacy snapshot regression (#69) --- tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs b/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs index 617b2df8..cbc69cbc 100644 --- a/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs +++ b/tests/Repo2C4.Core.Tests/RepositoryFactExtractorTests.cs @@ -13,7 +13,9 @@ public void ProjectOnlyInspectionPreservesLegacySnapshotSurface() fixture.Add("OnlyLib.csproj", "net10.0"); RepositoryScanOptions options = new(fixture.Root, "legacy_snapshot_surface"); - RepositorySnapshot result = RepositoryFactExtractor.Extract(options); + RepositorySnapshot result = RepositoryFactExtractor.Extract( + options, + TestContext.Current.CancellationToken); Assert.Null(result.SemanticC3Facts); Assert.DoesNotContain( From 3ec0ac9e1c81abd8b0c1e3aa88bd801d3ccdbd79 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:22:10 -0300 Subject: [PATCH 70/74] fix: parse primary constructors with bounded header scan (#69) --- .../Inspection/SemanticC3FactExtractor.cs | 141 +++++++++++++++++- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index d36a5152..ef3c6714 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -25,8 +25,8 @@ public static class SemanticC3FactExtractor @"[ \t]*(?:(?:public|internal|private|protected|abstract|sealed|static|partial|readonly|ref)\s+)*" + @"(?class|interface|struct|record(?:\s+class|\s+struct)?)\s+" + @"(?[A-Za-z_][A-Za-z0-9_]{0,127})(?:\s*<[^>{;\r\n]+>)?" + - @"(?:\s*\((?[^(){};]{0,1024})\))?" + - @"(?:\s*:\s*(?[^\{\r\n]+))?"); + @"(?:[ \t]*\([^()\r\n]{0,1024}\))?" + + @"(?:[ \t]*:[ \t]*(?[^\{\r\n]+))?"); private static readonly Regex MethodRegex = CreateRegex( @"(?m)^[ \t]*(?(?:[ \t]*\[[^\]\r\n]+\][ \t]*\r?\n)*)" + @@ -662,6 +662,123 @@ private static int FindMatchingOpenParenthesis(string value, int closeParenthesi return candidate.Length == 0 ? null : candidate; } + private static TypeHeaderInfo ParseTypeHeader( + string source, + int start, + int openBrace) + { + int hardEnd = openBrace >= 0 ? openBrace : source.Length; + int end = Math.Min(hardEnd, start + 2_048); + int index = start; + + while (index < end && char.IsWhiteSpace(source[index])) + { + index++; + } + + if (index < end && source[index] == '<') + { + int angleDepth = 0; + for (; index < end; index++) + { + if (source[index] == '<') + { + angleDepth++; + } + else if (source[index] == '>' && --angleDepth == 0) + { + index++; + break; + } + } + } + + while (index < end && char.IsWhiteSpace(source[index])) + { + index++; + } + + ParameterDeclaration[] primaryParameters = []; + if (index < end && source[index] == '(') + { + int close = FindMatchingCloseParenthesis(source, index); + if (close > index && + close < end && + close - index <= 1_025) + { + primaryParameters = ParseParameters( + source[(index + 1)..close]); + index = close + 1; + } + } + + while (index < end && char.IsWhiteSpace(source[index])) + { + index++; + } + + string[] bases = []; + if (index < end && source[index] == ':') + { + int baseStart = ++index; + int roundDepth = 0; + int angleDepth = 0; + for (; index < end; index++) + { + char current = source[index]; + if (current == '(') + { + roundDepth++; + } + else if (current == ')') + { + roundDepth = Math.Max(0, roundDepth - 1); + } + else if (current == '<') + { + angleDepth++; + } + else if (current == '>') + { + angleDepth = Math.Max(0, angleDepth - 1); + } + + if (roundDepth == 0 && + angleDepth == 0 && + StartsWithWord(source, index, "where")) + { + break; + } + } + + bases = SplitTypeList(source[baseStart..index]); + } + + return new TypeHeaderInfo(primaryParameters, bases); + } + + private static bool StartsWithWord( + string source, + int index, + string word) + { + if (index < 0 || + index + word.Length > source.Length || + !source.AsSpan(index, word.Length).SequenceEqual(word)) + { + return false; + } + + bool leftBoundary = + index == 0 || + !char.IsLetterOrDigit(source[index - 1]) && source[index - 1] != '_'; + int right = index + word.Length; + bool rightBoundary = + right >= source.Length || + !char.IsLetterOrDigit(source[right]) && source[right] != '_'; + return leftBoundary && rightBoundary; + } + private static SourceUnit ParseUnit( string projectPath, string sourcePath, @@ -681,10 +798,16 @@ private static SourceUnit ParseUnit( string name = match.Groups["name"].Value; string ns = NamespaceAt(namespaces, match.Index); string qualified = string.IsNullOrEmpty(ns) ? name : ns + "." + name; - string[] bases = SplitTypeList(match.Groups["bases"].Value); string[] attributes = ExtractAttributes(match.Groups["attrs"].Value); int openBrace = masked.IndexOf('{', match.Index + match.Length); + TypeHeaderInfo header = ParseTypeHeader( + masked, + match.Groups["name"].Index + match.Groups["name"].Length, + openBrace); + string[] bases = header.Bases.Length > 0 + ? header.Bases + : SplitTypeList(match.Groups["bases"].Value); int bodyStart = -1; int bodyEnd = -1; int bodyDepth = -1; @@ -713,18 +836,16 @@ private static SourceUnit ParseUnit( []); List methods = []; - if (match.Groups["primaryParams"].Success && + if (header.PrimaryParameters.Length > 0 && !type.IsInterface) { - ParameterDeclaration[] primaryParameters = - ParseParameters(match.Groups["primaryParams"].Value); methods.Add(new MethodDeclaration( type.Name, - MethodSymbolId(type, "#ctor", primaryParameters, isConstructor: true), + MethodSymbolId(type, "#ctor", header.PrimaryParameters, isConstructor: true), true, type.Line, [], - primaryParameters, + header.PrimaryParameters, -1, -1, match.Index)); @@ -1522,6 +1643,10 @@ private static Regex CreateRegex(string pattern) => RegexOptions.CultureInvariant | RegexOptions.NonBacktracking | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + private sealed record TypeHeaderInfo( + ParameterDeclaration[] PrimaryParameters, + string[] Bases); + private sealed record ParameterDeclaration(string Type, string Name); private sealed record MethodDeclaration( From d541f6caaee6ace9427c4dd673ef325142376f3c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:24:31 -0300 Subject: [PATCH 71/74] fix: reuse bounded parenthesis matcher for type headers (#69) --- src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index ef3c6714..fafde53f 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -701,7 +701,7 @@ private static TypeHeaderInfo ParseTypeHeader( ParameterDeclaration[] primaryParameters = []; if (index < end && source[index] == '(') { - int close = FindMatchingCloseParenthesis(source, index); + int close = FindMatchingParenthesisBounded(source, index); if (close > index && close < end && close - index <= 1_025) From a21c7e9b707242b485cdd2cda19ba389eb2f41f1 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 07:53:33 -0300 Subject: [PATCH 72/74] fix: relax bounded regex timeout under CI contention (#69) --- src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs index fafde53f..09d5c82a 100644 --- a/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs +++ b/src/Repo2C4.Core/Inspection/SemanticC3FactExtractor.cs @@ -1641,7 +1641,7 @@ private static Regex CreateRegex(string pattern) => new( pattern, RegexOptions.CultureInvariant | RegexOptions.NonBacktracking | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); + TimeSpan.FromMilliseconds(500)); private sealed record TypeHeaderInfo( ParameterDeclaration[] PrimaryParameters, From 3872ccc4a0e31474e3f047b842a6279741195a1c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 09:09:51 -0300 Subject: [PATCH 73/74] fix: address semantic snapshot and validation review feedback --- src/Repo2C4.Cli/CliApplication.cs | 109 +++++++++++++++++- src/Repo2C4.Cli/Properties/AssemblyInfo.cs | 3 + .../Contracts/ContractValidator.cs | 11 +- tests/Repo2C4.Cli.Tests/CliHostTests.cs | 64 ++++++++++ tests/Repo2C4.Core.Tests/ContractTests.cs | 43 +++++++ 5 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 src/Repo2C4.Cli/Properties/AssemblyInfo.cs diff --git a/src/Repo2C4.Cli/CliApplication.cs b/src/Repo2C4.Cli/CliApplication.cs index 12819034..a1cf6b16 100644 --- a/src/Repo2C4.Cli/CliApplication.cs +++ b/src/Repo2C4.Cli/CliApplication.cs @@ -25,7 +25,7 @@ public static class CliExitCodes internal static class CliApplication { - private const long MaxModelBytes = 4L * 1024 * 1024; + internal const long MaxModelBytes = 4L * 1024 * 1024; private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; public static async Task RunAsync( @@ -494,10 +494,115 @@ private static async Task WriteSnapshotAsync( RepositorySnapshot snapshot, CancellationToken cancellationToken) { - string json = NormalizeText(ContractJson.SerializeSnapshot(snapshot)); + RepositorySnapshot boundedSnapshot = FitSnapshotToCliInputBudget(snapshot); + string json = NormalizeText(ContractJson.SerializeSnapshot(boundedSnapshot)); await WriteTextFileAsync(output, json, overwrite: false, cancellationToken).ConfigureAwait(false); } + internal static RepositorySnapshot FitSnapshotToCliInputBudget(RepositorySnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (GetSerializedSnapshotByteCount(snapshot) <= MaxModelBytes) + { + return snapshot; + } + + SemanticC3FactSet? semanticFacts = snapshot.SemanticC3Facts; + if (semanticFacts is null || semanticFacts.Facts.IsEmpty) + { + throw SnapshotTooLarge(); + } + + RepositoryDiagnostic limitDiagnostic = new( + "semanticC3.snapshotByteLimit", + DiagnosticSeverity.Warning, + null, + "Semantic C3 facts were truncated so the persisted snapshot remains within the 4 MiB CLI input budget."); + RepositoryDiagnostic[] semanticDiagnostics = + [ + .. semanticFacts.Diagnostics + .Append(limitDiagnostic) + .Distinct() + .OrderBy(item => item.Code, StringComparer.Ordinal) + .ThenBy(item => item.RelativePath, StringComparer.Ordinal) + .ThenBy(item => item.Message, StringComparer.Ordinal), + ]; + SemanticC3Fact[] orderedFacts = + [ + .. semanticFacts.Facts.OrderBy(item => item.Id, StringComparer.Ordinal), + ]; + + int low = 0; + int high = orderedFacts.Length; + int bestCount = -1; + while (low <= high) + { + int count = low + ((high - low) / 2); + RepositorySnapshot candidate = snapshot with + { + SemanticC3Facts = semanticFacts with + { + Facts = [.. orderedFacts.Take(count)], + Diagnostics = [.. semanticDiagnostics], + }, + }; + + if (GetSerializedSnapshotByteCount(candidate) <= MaxModelBytes) + { + bestCount = count; + low = count + 1; + } + else + { + high = count - 1; + } + } + + if (bestCount >= 0) + { + return snapshot with + { + SemanticC3Facts = semanticFacts with + { + Facts = [.. orderedFacts.Take(bestCount)], + Diagnostics = [.. semanticDiagnostics], + }, + }; + } + + RepositorySnapshot withoutSemanticFacts = snapshot with + { + SemanticC3Facts = null, + Diagnostics = + [ + .. snapshot.Diagnostics + .Append(limitDiagnostic) + .Distinct() + .OrderBy(item => item.Code, StringComparer.Ordinal) + .ThenBy(item => item.RelativePath, StringComparer.Ordinal) + .ThenBy(item => item.Message, StringComparer.Ordinal), + ], + }; + if (GetSerializedSnapshotByteCount(withoutSemanticFacts) <= MaxModelBytes) + { + return withoutSemanticFacts; + } + + throw SnapshotTooLarge(); + } + + private static int GetSerializedSnapshotByteCount(RepositorySnapshot snapshot) => + Encoding.UTF8.GetByteCount(NormalizeText(ContractJson.SerializeSnapshot(snapshot))); + + private static ContractValidationException SnapshotTooLarge() => + new( + [ + new ContractError( + "snapshot.size", + "$", + "Snapshot exceeds the 4 MiB CLI input limit even after bounded Semantic C3 facts are omitted."), + ]); + private static async Task RunGenerateAsync( string[] args, TextWriter standardOutput, diff --git a/src/Repo2C4.Cli/Properties/AssemblyInfo.cs b/src/Repo2C4.Cli/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..2faf0e1a --- /dev/null +++ b/src/Repo2C4.Cli/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Repo2C4.Cli.Tests")] diff --git a/src/Repo2C4.Core/Contracts/ContractValidator.cs b/src/Repo2C4.Core/Contracts/ContractValidator.cs index 19788018..749c2c6b 100644 --- a/src/Repo2C4.Core/Contracts/ContractValidator.cs +++ b/src/Repo2C4.Core/Contracts/ContractValidator.cs @@ -446,9 +446,14 @@ private static void ValidateExternalIntegrationEvidence( Add(errors, "external.findings.limit", path + ".evidence", "Persisted external integration evidence exceeds the bounded limit."); } - Dictionary evidenceById = repositoryEvidence - .Where(item => item is not null) - .ToDictionary(item => item.Id, StringComparer.Ordinal); + Dictionary evidenceById = new(StringComparer.Ordinal); + foreach (Evidence? item in repositoryEvidence) + { + if (item is not null && !string.IsNullOrWhiteSpace(item.Id)) + { + _ = evidenceById.TryAdd(item.Id, item); + } + } HashSet ids = new(StringComparer.Ordinal); for (int index = 0; index < result.Evidence.Length; index++) diff --git a/tests/Repo2C4.Cli.Tests/CliHostTests.cs b/tests/Repo2C4.Cli.Tests/CliHostTests.cs index eb2e9339..01cd17b8 100644 --- a/tests/Repo2C4.Cli.Tests/CliHostTests.cs +++ b/tests/Repo2C4.Cli.Tests/CliHostTests.cs @@ -58,6 +58,70 @@ public void InspectProducesDeterministicCanonicalSnapshotForFixture() Assert.Empty(snapshot.Diagnostics); } + [Fact] + public void SnapshotBudgetTruncatesSemanticFactsToRemainConsumableByCli() + { + const string projectPath = "src/App/App.csproj"; + const string sourcePath = "src/App/Service.cs"; + SemanticC3Fact[] facts = + [ + .. Enumerable.Range(0, 5_000).Select(index => + { + string symbolId = "M:Demo.Service.Method" + index.ToString(System.Globalization.CultureInfo.InvariantCulture) + "()"; + SemanticC3SourceSymbolIdentity symbol = new( + StableIds.ForSemanticC3SourceSymbol(projectPath, symbolId), + projectPath, + symbolId); + return new SemanticC3Fact( + StableIds.ForSemanticC3Fact( + projectPath, + sourcePath, + symbolId, + SemanticC3FactKind.MethodDeclaration, + "semantic.test", + string.Empty), + projectPath, + sourcePath, + 1, + symbol, + SemanticC3FactKind.MethodDeclaration, + "semantic.test", + new string('x', 512), + null); + }), + ]; + RepositorySnapshot snapshot = new( + ContractSchema.Version, + "repo_budget", + [ + new RepositoryFile(projectPath, 100, null), + new RepositoryFile(sourcePath, 100, null), + ], + [], + []) + { + SemanticC3Facts = new SemanticC3FactSet( + SemanticC3ContractSchema.Version, + [.. facts], + []), + }; + + Assert.True( + System.Text.Encoding.UTF8.GetByteCount(ContractJson.SerializeSnapshot(snapshot) + "\n") + > CliApplication.MaxModelBytes); + + RepositorySnapshot bounded = CliApplication.FitSnapshotToCliInputBudget(snapshot); + string json = ContractJson.SerializeSnapshot(bounded) + "\n"; + + Assert.True(System.Text.Encoding.UTF8.GetByteCount(json) <= CliApplication.MaxModelBytes); + Assert.NotNull(bounded.SemanticC3Facts); + Assert.True(bounded.SemanticC3Facts.Facts.Length < facts.Length); + Assert.Contains( + bounded.SemanticC3Facts.Diagnostics, + diagnostic => diagnostic.Code == "semanticC3.snapshotByteLimit"); + Assert.Equal(json.TrimEnd('\n'), ContractJson.SerializeSnapshot(ContractJson.DeserializeSnapshot(json))); + } + [Fact] public void InspectImportsExternalEvidenceAndKeepsOutputPathOnStdout() { diff --git a/tests/Repo2C4.Core.Tests/ContractTests.cs b/tests/Repo2C4.Core.Tests/ContractTests.cs index 1ca28874..8bcc1658 100644 --- a/tests/Repo2C4.Core.Tests/ContractTests.cs +++ b/tests/Repo2C4.Core.Tests/ContractTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Repo2C4.Core.Contracts; +using Repo2C4.Core.ExternalIntegrations; using Xunit; namespace Repo2C4.Core.Tests; @@ -80,6 +81,48 @@ public void DuplicateEvidenceIdsAreRejected() Assert.Throws(() => ContractJson.SerializeSnapshot(snapshot)); } + [Fact] + public void DuplicateRepositoryEvidenceWithExternalEvidenceReturnsStructuredValidationErrors() + { + RepositorySnapshot snapshot = CreateSnapshot(); + Evidence persisted = snapshot.Evidence[1]; + ExternalIntegrationEvidence external = new( + persisted.Id, + persisted.Category, + persisted.Description, + null, + "src/App/App.csproj", + ExternalIntegrationKind.Http, + ExternalIntegrationDirection.Outbound, + "HTTP", + null, + null, + null, + null, + persisted.RelativePath, + persisted.Line ?? 1, + ExternalIntegrationConfidence.High, + ["test"]); + + snapshot = snapshot with + { + Evidence = [snapshot.Evidence[0], persisted, persisted], + ExternalIntegrationEvidence = new ExternalIntegrationEvidenceResult( + "1.6", + null, + null, + true, + false, + [external], + []), + }; + + ImmutableArray errors = ContractValidator.ValidateSnapshot(snapshot); + + AssertHasError(errors, "id.duplicate"); + Assert.Throws(() => ContractJson.SerializeSnapshot(snapshot)); + } + [Fact] public void DuplicateElementsAndRelationIdsAreRejected() { From ffff90fc70c098ea060bff25c9ea0c1d66c8f243 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sat, 3 Oct 2026 09:11:12 -0300 Subject: [PATCH 74/74] test: tighten review feedback regressions --- src/Repo2C4.Cli/CliApplication.cs | 12 ++++++------ tests/Repo2C4.Core.Tests/ContractTests.cs | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Repo2C4.Cli/CliApplication.cs b/src/Repo2C4.Cli/CliApplication.cs index a1cf6b16..083fffb4 100644 --- a/src/Repo2C4.Cli/CliApplication.cs +++ b/src/Repo2C4.Cli/CliApplication.cs @@ -596,12 +596,12 @@ private static int GetSerializedSnapshotByteCount(RepositorySnapshot snapshot) = private static ContractValidationException SnapshotTooLarge() => new( - [ - new ContractError( - "snapshot.size", - "$", - "Snapshot exceeds the 4 MiB CLI input limit even after bounded Semantic C3 facts are omitted."), - ]); + [ + new ContractError( + "snapshot.size", + "$", + "Snapshot exceeds the 4 MiB CLI input limit even after bounded Semantic C3 facts are omitted."), + ]); private static async Task RunGenerateAsync( string[] args, diff --git a/tests/Repo2C4.Core.Tests/ContractTests.cs b/tests/Repo2C4.Core.Tests/ContractTests.cs index 8bcc1658..4935c197 100644 --- a/tests/Repo2C4.Core.Tests/ContractTests.cs +++ b/tests/Repo2C4.Core.Tests/ContractTests.cs @@ -85,11 +85,10 @@ public void DuplicateEvidenceIdsAreRejected() public void DuplicateRepositoryEvidenceWithExternalEvidenceReturnsStructuredValidationErrors() { RepositorySnapshot snapshot = CreateSnapshot(); - Evidence persisted = snapshot.Evidence[1]; ExternalIntegrationEvidence external = new( - persisted.Id, - persisted.Category, - persisted.Description, + "ev_external", + "external.http.outbound", + "Observed outbound HTTP dependency.", null, "src/App/App.csproj", ExternalIntegrationKind.Http, @@ -99,14 +98,15 @@ public void DuplicateRepositoryEvidenceWithExternalEvidenceReturnsStructuredVali null, null, null, - persisted.RelativePath, - persisted.Line ?? 1, + "src/App/App.csproj", + 1, ExternalIntegrationConfidence.High, ["test"]); + Evidence persisted = external.ToRepositoryEvidence(); snapshot = snapshot with { - Evidence = [snapshot.Evidence[0], persisted, persisted], + Evidence = [.. snapshot.Evidence, persisted, persisted], ExternalIntegrationEvidence = new ExternalIntegrationEvidenceResult( "1.6", null,