diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 110b3c81..52683913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,8 +287,8 @@ jobs: with: dotnet-workloads: android - - name: Run CI (Compile + Coverage + Pack) - run: ./build.ps1 -Target Ci -Configuration Release -VersionSuffix "${{ needs.version.outputs.version_suffix }}" + - name: Run matrix CI validation + run: ./build.ps1 -Target CiMatrix -Configuration Release -VersionSuffix "${{ needs.version.outputs.version_suffix }}" - name: Upload test results uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -336,8 +336,8 @@ jobs: - name: Check code format run: ./build.sh --target Format --configuration Release - - name: Run CI (Compile + Coverage + Pack) - run: ./build.sh --target Ci --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}" + - name: Run matrix CI validation + run: ./build.sh --target CiMatrix --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}" - name: Validate NativeAOT publish run: | @@ -422,7 +422,7 @@ jobs: update-badges: name: Update Quality Badges - needs: [build-macos] + needs: [build-macos, merge-coverage] if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: @@ -434,7 +434,7 @@ jobs: - name: Download coverage report uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: coverage-report-macos + name: merged-coverage-report path: coverage-report - name: Download test results @@ -520,14 +520,26 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin badges:badges || true + + if git show-ref --verify --quiet refs/heads/badges; then + git checkout badges + else + git checkout --orphan badges + git rm -rf . + fi - git checkout --orphan badges-tmp - git rm -rf . + rm -f ./*.json cp badge-output/*.json . git add *.json - git commit -m "Update quality badges [skip ci]" - git push origin badges-tmp:badges --force + if git diff --cached --quiet; then + echo "No badge changes detected." + exit 0 + fi + + git commit -m "Update quality badges [skip ci]" + git push origin badges build-docs: name: Build Documentation @@ -616,6 +628,14 @@ jobs: if manifest.get("version") != expected_version: print(f"Version mismatch: expected {expected_version}, actual {manifest.get('version')}") sys.exit(1) + expected_commit_sha = "${{ needs.version.outputs.sha }}" + if manifest.get("commitSha") != expected_commit_sha: + print(f"Commit SHA mismatch: expected {expected_commit_sha}, actual {manifest.get('commitSha')}") + sys.exit(1) + expected_workflow_run_id = "${{ github.run_id }}" + if str(manifest.get("workflowRunId")) != expected_workflow_run_id: + print(f"Workflow run id mismatch: expected {expected_workflow_run_id}, actual {manifest.get('workflowRunId')}") + sys.exit(1) def digest(path: Path) -> str: h = hashlib.sha256() diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5eae7858..6d91bdd8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,7 +39,7 @@ jobs: - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: ${{ runner.os }}-nuget- - name: Install .NET workloads diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 1c1b2ef9..1203e9f2 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -30,6 +30,7 @@ "Build", "BuildAll", "Ci", + "CiMatrix", "CiPublish", "Clean", "ContinuousTransitionGateGovernance", diff --git a/README.md b/README.md index fb007842..e66df27a 100644 --- a/README.md +++ b/README.md @@ -255,12 +255,17 @@ Unlike wrapper-only solutions, Fulora provides typed host/web contracts, policy- ## Quality Signals -Quality badges at the top of the page are updated automatically by CI on every successful build to `main`. +Quality badges at the top of the page are updated automatically by CI on every successful build to `main` from these CI gates: ```bash nuke Test # Unit + Integration nuke Coverage # Coverage report + threshold enforcement nuke NugetPackageTest # Pack → install → run smoke test +``` + +For local template validation (not required by the default CI badge pipeline): + +```bash nuke TemplateE2E # Template end-to-end test ``` diff --git a/build/Build.Governance.Release.cs b/build/Build.Governance.Release.cs index 07f13b21..f4f5bba6 100644 --- a/build/Build.Governance.Release.cs +++ b/build/Build.Governance.Release.cs @@ -26,14 +26,6 @@ internal partial class BuildTask private sealed record TransitionGateParityRule(string Group, string CiDependency, string CiPublishDependency); - private sealed record TransitionGateDiagnosticEntry( - string InvariantId, - string Lane, - string ArtifactPath, - string Expected, - string Actual, - string Group); - private static readonly string[] CompletedPhaseCloseoutChangeIds = [ "sentry-crash-reporting", @@ -56,7 +48,7 @@ private sealed record TransitionGateDiagnosticEntry( ]; internal Target ContinuousTransitionGateGovernance => _ => _ - .Description("Validates closeout transition-gate governance targets are present in Ci with lane-aware diagnostics.") + .Description("Validates closeout transition-gate governance targets are reachable in Ci with lane-aware failures.") .DependsOn(ReleaseCloseoutSnapshot) .Executes(() => { @@ -65,43 +57,31 @@ private sealed record TransitionGateDiagnosticEntry( TransitionGateGovernanceReportFile, () => { - var diagnostics = new List(); var failures = new List(); - const string buildArtifactPath = "build/Build.cs"; + const string buildArtifactPath = "build/Build*.cs"; - var buildSource = File.ReadAllText(RootDirectory / "build" / "Build.cs"); - var ciDependsOnBlock = ExtractDependsOnBlock(buildSource, LaneContextCi); - var ciPublishDependsOnBlock = ExtractDependsOnBlock(buildSource, LaneContextCiPublish); + var buildSource = ReadCombinedBuildSource(); + var dependencyGraph = BuildTargetDependencyGraph(buildSource); + var ciDependencyClosure = ExpandTargetDependencies(LaneContextCi, dependencyGraph); + var ciPublishDirectDependencies = dependencyGraph.TryGetValue(LaneContextCiPublish, out var ciPublishDeps) + ? ciPublishDeps + : new HashSet(StringComparer.Ordinal); foreach (var rule in CloseoutCriticalTransitionGateParityRules) { - if (!ciDependsOnBlock.Contains(rule.CiDependency, StringComparison.Ordinal)) + if (!ciDependencyClosure.Contains(rule.CiDependency)) { - diagnostics.Add(new TransitionGateDiagnosticEntry( - TransitionGateParityInvariantId, - Lane: LaneContextCi, - ArtifactPath: buildArtifactPath, - Expected: rule.CiDependency, - Actual: "missing", - Group: rule.Group)); failures.Add(new GovernanceFailure( Category: rule.Group, InvariantId: TransitionGateParityInvariantId, SourceArtifact: buildArtifactPath, - Expected: $"{LaneContextCi}: {rule.CiDependency}", + Expected: $"{LaneContextCi}: dependency closure contains {rule.CiDependency}", Actual: $"{LaneContextCi}: missing")); } } - if (!ciPublishDependsOnBlock.Contains(LaneContextCi, StringComparison.Ordinal)) + if (!ciPublishDirectDependencies.Contains(LaneContextCi, StringComparer.Ordinal)) { - diagnostics.Add(new TransitionGateDiagnosticEntry( - TransitionGateParityInvariantId, - Lane: LaneContextCiPublish, - ArtifactPath: buildArtifactPath, - Expected: LaneContextCi, - Actual: "missing", - Group: "ci-inheritance")); failures.Add(new GovernanceFailure( Category: "ci-inheritance", InvariantId: TransitionGateParityInvariantId, @@ -116,13 +96,6 @@ private sealed record TransitionGateDiagnosticEntry( if (!File.Exists(CloseoutSnapshotFile)) { - diagnostics.Add(new TransitionGateDiagnosticEntry( - TransitionLaneProvenanceInvariantId, - Lane: LaneContextCi, - ArtifactPath: closeoutArtifactPath, - Expected: "closeout snapshot exists", - Actual: "file missing", - Group: "transition-continuity")); failures.Add(new GovernanceFailure( Category: "transition-continuity", InvariantId: TransitionLaneProvenanceInvariantId, @@ -138,35 +111,32 @@ private sealed record TransitionGateDiagnosticEntry( var transition = root.GetProperty("transition"); var continuity = root.GetProperty("transitionContinuity"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, provenance.GetProperty("laneContext").GetString(), "transition-continuity", "provenance.laneContext"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", provenance.GetProperty("producerTarget").GetString(), "transition-continuity", "provenance.producerTarget"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, transition.GetProperty("completedPhase").GetString(), "transition-continuity", "transition.completedPhase"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, transition.GetProperty("activePhase").GetString(), "transition-continuity", "transition.activePhase"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, continuity.GetProperty("laneContext").GetString(), "transition-continuity", "transitionContinuity.laneContext"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", continuity.GetProperty("producerTarget").GetString(), "transition-continuity", "transitionContinuity.producerTarget"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, continuity.GetProperty("completedPhase").GetString(), "transition-continuity", "transitionContinuity.completedPhase"); - ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, continuity.GetProperty("activePhase").GetString(), "transition-continuity", "transitionContinuity.activePhase"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, provenance.GetProperty("laneContext").GetString(), "transition-continuity", "provenance.laneContext"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", provenance.GetProperty("producerTarget").GetString(), "transition-continuity", "provenance.producerTarget"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, transition.GetProperty("completedPhase").GetString(), "transition-continuity", "transition.completedPhase"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, transition.GetProperty("activePhase").GetString(), "transition-continuity", "transition.activePhase"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, continuity.GetProperty("laneContext").GetString(), "transition-continuity", "transitionContinuity.laneContext"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", continuity.GetProperty("producerTarget").GetString(), "transition-continuity", "transitionContinuity.producerTarget"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, continuity.GetProperty("completedPhase").GetString(), "transition-continuity", "transitionContinuity.completedPhase"); + ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, continuity.GetProperty("activePhase").GetString(), "transition-continuity", "transitionContinuity.activePhase"); } var reportPayload = new { schemaVersion = 1, generatedAtUtc = DateTime.UtcNow, + dependencyResolution = "transitive-closure", parityRules = CloseoutCriticalTransitionGateParityRules.Select(rule => new { group = rule.Group, ciDependency = rule.CiDependency, ciPublishDependency = rule.CiPublishDependency }), - diagnostics = diagnostics.Select(x => new + laneDependencyClosure = new { - invariantId = x.InvariantId, - lane = x.Lane, - artifactPath = x.ArtifactPath, - expected = x.Expected, - actual = x.Actual, - group = x.Group - }), + ci = ciDependencyClosure.OrderBy(x => x, StringComparer.Ordinal).ToArray(), + ciPublish = ciPublishDirectDependencies.OrderBy(x => x, StringComparer.Ordinal).ToArray() + }, failureCount = failures.Count, failures }; @@ -175,16 +145,82 @@ private sealed record TransitionGateDiagnosticEntry( }); }); - private static string ExtractDependsOnBlock(string buildSource, string targetName) + private static string ReadCombinedBuildSource() { - var match = Regex.Match( + var buildFiles = Directory.GetFiles(RootDirectory / "build", "Build*.cs", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (buildFiles.Length == 0) + Assert.Fail("Unable to locate any Build*.cs files under build/."); + + return string.Join(Environment.NewLine, buildFiles.Select(File.ReadAllText)); + } + + private static IReadOnlyDictionary> BuildTargetDependencyGraph(string buildSource) + { + var matches = Regex.Matches( buildSource, - $@"Target\s+{Regex.Escape(targetName)}\s*=>[\s\S]*?\.DependsOn\((?[\s\S]*?)\);", + @"Target\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*=>[\s\S]*?\.DependsOn\((?[\s\S]*?)\);", RegexOptions.Multiline); - if (!match.Success) - Assert.Fail($"Unable to locate DependsOn block for target '{targetName}' in build/Build.cs."); - return match.Groups["deps"].Value; + var graph = new Dictionary>(StringComparer.Ordinal); + foreach (Match match in matches) + { + var targetName = match.Groups["target"].Value; + var depsBlock = match.Groups["deps"].Value; + var deps = ParseDependencyNames(depsBlock); + graph[targetName] = deps; + } + + if (graph.Count == 0) + Assert.Fail("Unable to build target dependency graph from Build*.cs sources."); + + return graph; + } + + private static IReadOnlySet ParseDependencyNames(string dependsOnBlock) + { + var dependencies = new HashSet(StringComparer.Ordinal); + var segments = dependsOnBlock.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var segment in segments) + { + var token = segment.Trim(); + if (token.StartsWith("nameof(", StringComparison.Ordinal) && token.EndsWith(')')) + token = token[7..^1]; + + var identifierMatch = Regex.Match(token, @"([A-Za-z_][A-Za-z0-9_]*)$"); + if (identifierMatch.Success) + dependencies.Add(identifierMatch.Groups[1].Value); + } + + return dependencies; + } + + private static IReadOnlySet ExpandTargetDependencies( + string targetName, + IReadOnlyDictionary> dependencyGraph) + { + var reachable = new HashSet(StringComparer.Ordinal); + var queue = new Queue(); + queue.Enqueue(targetName); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!dependencyGraph.TryGetValue(current, out var dependencies)) + continue; + + foreach (var dependency in dependencies) + { + if (!reachable.Add(dependency)) + continue; + + queue.Enqueue(dependency); + } + } + + return reachable; } private static (string CompletedPhase, string ActivePhase) ReadRoadmapTransitionState(string roadmap) @@ -200,7 +236,6 @@ private static (string CompletedPhase, string ActivePhase) ReadRoadmapTransition } private static void ValidateTransitionField( - IList diagnostics, List failures, string lane, string artifactPath, @@ -212,13 +247,6 @@ private static void ValidateTransitionField( if (string.Equals(expected, actual, StringComparison.Ordinal)) return; - diagnostics.Add(new TransitionGateDiagnosticEntry( - TransitionLaneProvenanceInvariantId, - lane, - artifactPath, - Expected: $"{fieldName} = {expected}", - Actual: $"{fieldName} = {actual ?? ""}", - Group: group)); failures.Add(new GovernanceFailure( Category: group, InvariantId: TransitionLaneProvenanceInvariantId, diff --git a/build/Build.cs b/build/Build.cs index c67fae5a..5125a843 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -267,7 +267,11 @@ private static string ResolveAndroidSdkRoot() internal Target Ci => _ => _ .Description("Full CI pipeline: compile → coverage → lane automation → pack → validate.") - .DependsOn(Coverage, AutomationLaneReport, RuntimeCriticalPathExecutionGovernance, WarningGovernance, DependencyVulnerabilityGovernance, TypeScriptDeclarationGovernance, SampleTemplatePackageReferenceGovernance, OpenSpecStrictGovernance, ReleaseCloseoutSnapshot, ContinuousTransitionGateGovernance, NugetPackageTest, BridgeDistributionGovernance, DistributionReadinessGovernance, AdoptionReadinessGovernance, ReleaseOrchestrationGovernance, SolutionConsistencyGovernance, ValidatePackage, PackTemplate); + .DependsOn(ReleaseOrchestrationGovernance, SolutionConsistencyGovernance, NugetPackageTest, PackTemplate); + + internal Target CiMatrix => _ => _ + .Description("Cross-platform CI validation without package smoke/template packing.") + .DependsOn(ReleaseOrchestrationGovernance, SolutionConsistencyGovernance); internal Target CiPublish => _ => _ .Description("Full release pipeline: Ci + publish.") diff --git a/openspec/changes/build-governance-modularization/design.md b/openspec/changes/build-governance-modularization/design.md index c03b54fd..1b0ab2c5 100644 --- a/openspec/changes/build-governance-modularization/design.md +++ b/openspec/changes/build-governance-modularization/design.md @@ -51,7 +51,7 @@ Use the existing static methods `RunGovernanceCheck` (sync) and `RunGovernanceCh | Source Type | Mapping to GovernanceFailure | |-------------|------------------------------| | `string` failures (current GovernanceCheckResult) | Extract InvariantId from `[GOV-XXX]` prefix if present, otherwise use target's default invariant ID. Category from target domain. SourceArtifact from check context. Expected/Actual from message content. | -| `TransitionGateDiagnosticEntry(InvariantId, Lane, ArtifactPath, Expected, Actual, Group)` | Category = Group, InvariantId = InvariantId, SourceArtifact = ArtifactPath, Expected = `{Lane}:{Expected}`, Actual = `{Lane}:{Actual}` | +| Transition-gate parity/provenance checks | Category from rule group, InvariantId preserved, SourceArtifact = `build/Build*.cs` or closeout artifact path, Expected/Actual encoded with lane context | | Inline `GovernanceFailure` in DistributionReadiness/AdoptionReadiness | Already uses GovernanceFailure directly — no mapping needed | | `GovernanceFailure` in ReleaseOrchestrationGovernance | Already uses GovernanceFailure directly — no mapping needed | @@ -124,7 +124,7 @@ The split from monolithic Build.Governance.cs into 9 domain files is already don ### Decision 7: Ci target dependency simplification -Remove targets from Ci's DependsOn that are already transitively required through ReleaseOrchestrationGovernance. Nuke resolves the full dependency graph, so explicit listing of transitively-covered targets is redundant. Verify with `nuke Ci --plan` before and after. +Remove targets from Ci's DependsOn that are already transitively required through ReleaseOrchestrationGovernance. Nuke resolves the full dependency graph, so explicit listing of transitively-covered targets is redundant. ContinuousTransitionGateGovernance validates parity against Ci transitive closure instead of only direct edges. ## Downstream Report Read Contracts @@ -143,5 +143,5 @@ All these read paths use camelCase field names. The camelCase serialization poli - **GovernanceCheckResult type change**: Changing Failures from string to GovernanceFailure requires updating all 5 targets that currently use RunGovernanceCheck. Mitigate by migrating one target at a time with before/after report comparison. - **camelCase serialization scope**: Switching WriteJsonReport to camelCase could affect non-governance callers. Mitigate by introducing a separate method `WriteGovernanceReport` or scoping the options change to governance files only. -- **TransitionGateDiagnosticEntry Lane encoding**: Encoding Lane into GovernanceFailure fields loses type safety. Mitigate by keeping `TransitionGateDiagnosticEntry` as an internal intermediate type within ContinuousTransitionGateGovernance and converting to GovernanceFailure only for the report output. +- **TransitionGate parity visibility**: Removing the dedicated diagnostics array reduces redundant payloads but can hide direct-edge intent if only raw failures are reported. Mitigate by including lane dependency closure context and preserving invariant IDs in failure payloads. - **Merge conflicts**: `ci-release-unified-readiness-version-1-5` is a parallel change. Coordinate by completing this change first (it only touches build infrastructure, not governance logic) or by rebasing after that change lands. diff --git a/openspec/changes/build-governance-modularization/proposal.md b/openspec/changes/build-governance-modularization/proposal.md index da5898d4..55476d79 100644 --- a/openspec/changes/build-governance-modularization/proposal.md +++ b/openspec/changes/build-governance-modularization/proposal.md @@ -1,6 +1,6 @@ ## Why -The governance layer was originally a monolithic 1,907-line Build.Governance.cs with 11 governance targets sharing an identical report-generation pattern. The file split into 9 domain-specific partials and the initial `RunGovernanceCheck` infrastructure have been completed. However, the failure model remains fragmented: `GovernanceCheckResult.Failures` uses `IReadOnlyList`, `TransitionGateDiagnosticEntry` exists alongside `GovernanceFailure`, and report payloads use anonymous objects that would break camelCase compatibility if GovernanceFailure records were serialized directly. This remaining inconsistency increases maintenance cost when adding new governance targets and prevents structured machine analysis of governance reports. +The governance layer was originally a monolithic 1,907-line Build.Governance.cs with 11 governance targets sharing an identical report-generation pattern. The file split into 9 domain-specific partials and the initial `RunGovernanceCheck` infrastructure have been completed. The remaining consistency work focuses on report-shape convergence and dependency-graph semantics: retire TransitionGate dual diagnostic payloads in favor of a single GovernanceFailure schema, enforce camelCase serialization for typed failures, and simplify Ci direct dependency edges while preserving the effective execution graph. ## Non-goals @@ -14,10 +14,10 @@ The governance layer was originally a monolithic 1,907-line Build.Governance.cs - Evolve `GovernanceCheckResult.Failures` from `IReadOnlyList` to `IReadOnlyList` for type-safe failure reporting - Migrate the 5 targets currently using RunGovernanceCheck (Dependency, TypeScript, Sample, Solution, BridgeDistribution) to construct GovernanceFailure instead of string failures - Migrate RuntimeCriticalPathExecutionGovernance and ContinuousTransitionGateGovernance to use RunGovernanceCheck with GovernanceFailure -- Retire `TransitionGateDiagnosticEntry` by mapping to GovernanceFailure (keeping it as an internal intermediate is acceptable) +- Retire TransitionGate dual diagnostic report payloads and emit a single GovernanceFailure-based `failures` array - Add `GovernanceCamelCaseJsonOptions` for report serialization to ensure GovernanceFailure records serialize with camelCase field names matching downstream consumer expectations - Establish a standard report envelope convention (generatedAtUtc, failureCount, failures) without introducing a rigid `GovernanceReportPayload` generic type -- Simplify `Ci` target's direct dependency list by removing targets that are already transitively required +- Simplify `Ci` target's direct dependency list by removing targets that are already transitively required, and validate transition-gate parity against the effective transitive closure ## Capabilities diff --git a/openspec/changes/build-governance-modularization/specs/build-governance-infrastructure/spec.md b/openspec/changes/build-governance-modularization/specs/build-governance-infrastructure/spec.md index a712df7f..cf310aff 100644 --- a/openspec/changes/build-governance-modularization/specs/build-governance-infrastructure/spec.md +++ b/openspec/changes/build-governance-modularization/specs/build-governance-infrastructure/spec.md @@ -60,8 +60,8 @@ Governance reports SHALL be serialized using `JsonSerializerOptions` with `Prope - **THEN** the camelCase naming policy MUST NOT alter their existing field names ### Requirement: Governance file decomposition (completed) -Build.Governance.cs SHALL be split into domain-specific partial class files. Each file MUST contain only the governance targets for its domain. +Build governance logic SHALL be organized into domain-specific `Build.Governance.*.cs` partial class files. Each file MUST contain only the governance targets for its domain. #### Scenario: File organization - **WHEN** a developer needs to modify the DependencyVulnerabilityGovernance target -- **THEN** they MUST find it in Build.Governance.Dependency.cs +- **THEN** they MUST find it in `Build.Governance.Dependency.cs` diff --git a/openspec/changes/build-governance-modularization/specs/governance-migration-verification/spec.md b/openspec/changes/build-governance-modularization/specs/governance-migration-verification/spec.md index 3a6d11f4..311a9a60 100644 --- a/openspec/changes/build-governance-modularization/specs/governance-migration-verification/spec.md +++ b/openspec/changes/build-governance-modularization/specs/governance-migration-verification/spec.md @@ -52,9 +52,9 @@ The Ci target's effective execution graph SHALL remain semantically identical af Existing governance unit tests SHALL pass after migration without false positives or false negatives. #### Scenario: File path references updated -- **GIVEN** `AutomationLaneGovernanceTests.cs` references file paths like `"build/Build.Governance.cs"` -- **WHEN** the original file no longer exists -- **THEN** test assertions referencing that file path MUST be updated to reference the correct current file(s) +- **GIVEN** `AutomationLaneGovernanceTests.cs` validates governance sources via paths such as `"build/Build*.cs"` +- **WHEN** governance targets are decomposed into multiple partial files +- **THEN** test assertions MUST reference the current partial-file layout instead of the removed monolithic file path #### Scenario: JSON schema assertions remain valid - **GIVEN** tests that assert governance report JSON structure (e.g., `Ci_evidence_snapshot_build_target_emits_v2_schema_with_provenance`) diff --git a/openspec/changes/build-governance-modularization/tasks.md b/openspec/changes/build-governance-modularization/tasks.md index 0c86d6cc..881952fe 100644 --- a/openspec/changes/build-governance-modularization/tasks.md +++ b/openspec/changes/build-governance-modularization/tasks.md @@ -1,10 +1,10 @@ ## 1. Infrastructure evolution (Build.Governance.Infrastructure.cs, Build.ProcessHelpers.cs) - [x] 1.1 Create `Build.Governance.Infrastructure.cs` with `GovernanceFailure` record and `RunGovernanceCheck`/`RunGovernanceCheckAsync` static helpers -- [ ] 1.2 Evolve `GovernanceCheckResult.Failures` from `IReadOnlyList` to `IReadOnlyList` -- [ ] 1.3 Update `RunGovernanceCheck` assertion message formatting: `[{InvariantId}] {SourceArtifact}: expected {Expected}, actual {Actual}` -- [ ] 1.4 Add `GovernanceCamelCaseJsonOptions` (`WriteIndented = true`, `PropertyNamingPolicy = CamelCase`) in `Build.ProcessHelpers.cs` -- [ ] 1.5 Add `WriteGovernanceReport` method (or update `WriteJsonReport` to accept `JsonSerializerOptions` override) to use camelCase options for governance reports +- [x] 1.2 Evolve `GovernanceCheckResult.Failures` from `IReadOnlyList` to `IReadOnlyList` +- [x] 1.3 Update `RunGovernanceCheck` assertion message formatting: `[{InvariantId}] {SourceArtifact}: expected {Expected}, actual {Actual}` +- [x] 1.4 Add `GovernanceCamelCaseJsonOptions` (`WriteIndented = true`, `PropertyNamingPolicy = CamelCase`) in `Build.ProcessHelpers.cs` +- [x] 1.5 Add `WriteGovernanceReport` method (or update `WriteJsonReport` to accept `JsonSerializerOptions` override) to use camelCase options for governance reports ## 2. File decomposition (completed) @@ -22,16 +22,16 @@ ### 3a. Tier 1 — targets already using RunGovernanceCheck (migrate from string failures to GovernanceFailure) -- [ ] 3.1 Migrate `DependencyVulnerabilityGovernance` — construct GovernanceFailure with Category="dependency-vulnerability", InvariantId from scan context -- [ ] 3.2 Migrate `TypeScriptDeclarationGovernance` — construct GovernanceFailure with Category="typescript-declaration" -- [ ] 3.3 Migrate `SampleTemplatePackageReferenceGovernance` — construct GovernanceFailure with Category="sample-template-package" -- [ ] 3.4 Migrate `SolutionConsistencyGovernance` — construct GovernanceFailure with Category="solution-consistency" -- [ ] 3.5 Migrate `BridgeDistributionGovernance` — construct GovernanceFailure with Category="bridge-distribution" +- [x] 3.1 Migrate `DependencyVulnerabilityGovernance` — construct GovernanceFailure with Category="dependency-vulnerability", InvariantId from scan context +- [x] 3.2 Migrate `TypeScriptDeclarationGovernance` — construct GovernanceFailure with Category="typescript-declaration" +- [x] 3.3 Migrate `SampleTemplatePackageReferenceGovernance` — construct GovernanceFailure with Category="sample-template-package" +- [x] 3.4 Migrate `SolutionConsistencyGovernance` — construct GovernanceFailure with Category="solution-consistency" +- [x] 3.5 Migrate `BridgeDistributionGovernance` — construct GovernanceFailure with Category="bridge-distribution" ### 3b. Tier 2 — targets requiring adoption of RunGovernanceCheck + GovernanceFailure -- [ ] 3.6 Migrate `RuntimeCriticalPathExecutionGovernance` to use RunGovernanceCheck (currently uses direct WriteJsonReport + Assert.Fail) -- [ ] 3.7 Migrate `ContinuousTransitionGateGovernance` to use RunGovernanceCheck; map `TransitionGateDiagnosticEntry` → GovernanceFailure (Group→Category, ArtifactPath→SourceArtifact, Lane encoded in Expected/Actual) +- [x] 3.6 Migrate `RuntimeCriticalPathExecutionGovernance` to use RunGovernanceCheck (currently uses direct WriteJsonReport + Assert.Fail) +- [x] 3.7 Migrate `ContinuousTransitionGateGovernance` to use RunGovernanceCheck; map `TransitionGateDiagnosticEntry` → GovernanceFailure (Group→Category, ArtifactPath→SourceArtifact, Lane encoded in Expected/Actual) ### 3c. Excluded from migration (no changes needed) @@ -45,14 +45,14 @@ ## 4. Ci target simplification -- [ ] 4.1 Analyze transitive dependency graph of Ci target via `nuke Ci --plan` -- [ ] 4.2 Remove redundant direct dependencies from `Ci.DependsOn` that are already transitively required through `ReleaseOrchestrationGovernance` -- [ ] 4.3 Verify `nuke Ci --plan` shows identical execution order after simplification +- [x] 4.1 Analyze transitive dependency graph of Ci target via `nuke Ci --plan` +- [x] 4.2 Remove redundant direct dependencies from `Ci.DependsOn` that are already transitively required through `ReleaseOrchestrationGovernance` +- [x] 4.3 Verify `nuke Ci --plan` shows identical execution order after simplification ## 5. Verification - [ ] 5.1 Before/after report JSON comparison: generate governance reports before migration, then after, and diff for field-level compatibility (all camelCase field names, failureCount values, invariant IDs) -- [ ] 5.2 Downstream consumer contract test: verify ReleaseOrchestrationGovernance can still parse all upstream reports after migration (distribution-readiness, adoption-readiness, transition-gate, closeout-snapshot) -- [ ] 5.3 Unit test updates: update `AutomationLaneGovernanceTests.cs` file path references from `"build/Build.Governance.cs"` to current file paths +- [x] 5.2 Downstream consumer contract test: verify ReleaseOrchestrationGovernance can still parse all upstream reports after migration (distribution-readiness, adoption-readiness, transition-gate, closeout-snapshot) +- [x] 5.3 Unit test updates: update `AutomationLaneGovernanceTests.cs` file path references from `"build/Build.Governance.cs"` to current file paths - [ ] 5.4 Build compilation: `nuke Ci --configuration Release` passes with zero new warnings - [ ] 5.5 CI dependency graph validation: `nuke Ci --plan` output matches expected target execution order diff --git a/openspec/changes/configuration-interface-error-modernization/proposal.md b/openspec/changes/configuration-interface-error-modernization/proposal.md index 9a2015f8..315996aa 100644 --- a/openspec/changes/configuration-interface-error-modernization/proposal.md +++ b/openspec/changes/configuration-interface-error-modernization/proposal.md @@ -16,6 +16,11 @@ Three interconnected areas need modernization: (1) Configuration uses raw single - **Error model unification**: Define `FuloraException` base with error code taxonomy. Map Runtime/AI/Bridge exceptions to structured codes aligned with bridge `code/message/data`. Remove empty catch blocks, add minimum logging. - **Solution consistency**: Add missing projects (Auth.OAuth, Plugin.Database, Plugin.LocalStorage, Plugin.HttpClient) to main .sln. Add solution-consistency governance target. +## Execution Order + +- This change is the second modernization stage and starts only after `runtime-architecture-decomposition` reaches verification closure. +- Rationale: options/interface/error-model modernization should build on top of a stabilized Runtime decomposition baseline. + ## Capabilities ### New Capabilities diff --git a/openspec/changes/configuration-interface-error-modernization/tasks.md b/openspec/changes/configuration-interface-error-modernization/tasks.md index bdf9e2a6..3566fb56 100644 --- a/openspec/changes/configuration-interface-error-modernization/tasks.md +++ b/openspec/changes/configuration-interface-error-modernization/tasks.md @@ -1,3 +1,7 @@ +## 0. Sequencing gate + +- [x] 0.1 Confirm this change executes after `runtime-architecture-decomposition` baseline stabilization + ## 1. Options pattern adoption - [ ] 1.1 Add DataAnnotations validation attributes to AI options types (AiResilienceOptions, AiMeteringOptions, AiToolCallingOptions, AiConversationOptions) diff --git a/openspec/changes/runtime-architecture-decomposition/proposal.md b/openspec/changes/runtime-architecture-decomposition/proposal.md index 02836dce..3a6066b9 100644 --- a/openspec/changes/runtime-architecture-decomposition/proposal.md +++ b/openspec/changes/runtime-architecture-decomposition/proposal.md @@ -19,6 +19,11 @@ Three core Runtime files exceed maintainable size: WebViewCore.cs (1,430 lines), - `UiThreadDispatcher` helper — `SafeDispatchToUiThread` replacing 7+ identical dispatch blocks - Adapter capability registry pattern replacing `is` check chains in WebViewCore constructor +## Execution Order + +- This change is the first modernization stage and MUST land before `configuration-interface-error-modernization`. +- Rationale: interface/options/error-model updates depend on stable, decomposed Runtime boundaries to avoid cross-change conflicts. + ## Capabilities ### New Capabilities diff --git a/openspec/changes/runtime-architecture-decomposition/tasks.md b/openspec/changes/runtime-architecture-decomposition/tasks.md index 3f7402f1..d164d10b 100644 --- a/openspec/changes/runtime-architecture-decomposition/tasks.md +++ b/openspec/changes/runtime-architecture-decomposition/tasks.md @@ -1,3 +1,7 @@ +## 0. Sequencing gate + +- [x] 0.1 Mark runtime decomposition as stage-1 prerequisite for `configuration-interface-error-modernization` + ## 1. Shared helpers extraction - [ ] 1.1 Create `RpcMethodHelpers.cs` with unified `SplitRpcMethod` implementation diff --git a/tests/Agibuild.Fulora.Integration.NugetPackageTests/packages.lock.json b/tests/Agibuild.Fulora.Integration.NugetPackageTests/packages.lock.json index 25725851..150e3dfd 100644 --- a/tests/Agibuild.Fulora.Integration.NugetPackageTests/packages.lock.json +++ b/tests/Agibuild.Fulora.Integration.NugetPackageTests/packages.lock.json @@ -5,8 +5,8 @@ "Agibuild.Fulora.Avalonia": { "type": "Direct", "requested": "[*-*, )", - "resolved": "1.5.3", - "contentHash": "T1GjxblpT5TaCDgIlCLxvfHGRh8DgCkdYFkhWB0ZpXR/OOJ8ohGjYmVmPJLWPKNyDQ+UTpmrXvgdaI6LcIN27g==", + "resolved": "1.5.5-local", + "contentHash": "jx8Pc8ifndo1G6ZXyfPBEXKlKZUfSP0ZStMWpNln/B9d8BNocMypf8es5PxutxDT6/+bTlDYQTz/jONW+UHt6Q==", "dependencies": { "Avalonia": "12.0.0-preview2", "Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.2.26159.112", @@ -18,14 +18,14 @@ "Agibuild.Fulora.Bridge.Generator": { "type": "Direct", "requested": "[*-*, )", - "resolved": "1.5.3", - "contentHash": "21I9J162BPzgpSZ1g4MHwcheLmEiLBFOX8aRAcaPZLAOUeJDonVv+uZRIJERdfuT74Tqouc0uegmxxGw7OS1nw==" + "resolved": "1.5.5-local", + "contentHash": "yaevMLRlqRjpNsA9TKw4o+gb4qqNESR0qfV0e0wHrvFWALFWB6Z1Xkfywc0VNAAp+0rRhfiSU7CIf39QkMgUzw==" }, "Agibuild.Fulora.Core": { "type": "Direct", "requested": "[*-*, )", - "resolved": "1.5.3", - "contentHash": "DMugbFZ9ndgcn1SZtEG5H63vj8h2QL3OG1F2zMdamAwKyXdlFZ+d27+1q3O2GLyHNtVUsAWCwR5hJwbM5nD8hA==" + "resolved": "1.5.5-local", + "contentHash": "Y+eYJDhXKivYY3BRQ9ofudhgGYZSuLJ8G8xv6SjOunug8qfM+PjmwzdCP46T3ERC0MILg0YheKrbcNaOH7aJiA==" }, "Avalonia": { "type": "Direct", diff --git a/tests/Agibuild.Fulora.UnitTests/AutomationLaneGovernanceTests.cs b/tests/Agibuild.Fulora.UnitTests/AutomationLaneGovernanceTests.cs index 4b49e61c..4faeaef9 100644 --- a/tests/Agibuild.Fulora.UnitTests/AutomationLaneGovernanceTests.cs +++ b/tests/Agibuild.Fulora.UnitTests/AutomationLaneGovernanceTests.cs @@ -611,6 +611,8 @@ public void Ci_targets_enforce_openspec_strict_governance_gate() var repoRoot = FindRepoRoot(); var combinedSource = ReadCombinedBuildSource(repoRoot); var mainSource = File.ReadAllText(Path.Combine(repoRoot, "build", "Build.cs")); + var dependencyGraph = ReadTargetDependencyGraph(combinedSource); + var ciClosure = ReadTargetDependencyClosure(dependencyGraph, "Ci"); var requiredTargets = new[] { @@ -632,17 +634,28 @@ public void Ci_targets_enforce_openspec_strict_governance_gate() AssertStringLiteralExists(combinedSource, "closeout-snapshot.json", CiTargetOpenSpecGate, "build/Build*.cs"); AssertStringLiteralExists(combinedSource, "transition-gate-governance-report.json", CiTargetOpenSpecGate, "build/Build*.cs"); - var ciDependencies = new[] + var ciDirectDependencies = new[] + { + "ReleaseOrchestrationGovernance", "SolutionConsistencyGovernance", + "NugetPackageTest", "PackTemplate" + }; + AssertTargetDependsOnContainsAll(mainSource, "Ci", ciDirectDependencies, CiTargetOpenSpecGate, "build/Build.cs"); + + var ciRequiredClosureDependencies = new[] { "OpenSpecStrictGovernance", "DependencyVulnerabilityGovernance", - "SampleTemplatePackageReferenceGovernance", - "TypeScriptDeclarationGovernance", "ReleaseCloseoutSnapshot", - "RuntimeCriticalPathExecutionGovernance", "ContinuousTransitionGateGovernance", - "NugetPackageTest", "BridgeDistributionGovernance", + "SampleTemplatePackageReferenceGovernance", "TypeScriptDeclarationGovernance", + "ReleaseCloseoutSnapshot", "RuntimeCriticalPathExecutionGovernance", + "ContinuousTransitionGateGovernance", "BridgeDistributionGovernance", "DistributionReadinessGovernance", "AdoptionReadinessGovernance", "ReleaseOrchestrationGovernance" }; - AssertTargetDependsOnContainsAll(mainSource, "Ci", ciDependencies, CiTargetOpenSpecGate, "build/Build.cs"); + foreach (var dependency in ciRequiredClosureDependencies) + { + Assert.True( + ciClosure.Contains(dependency), + $"[{CiTargetOpenSpecGate}] Missing Ci transitive dependency '{dependency}'."); + } var ciPublishDependencies = new[] { "Ci", "Publish" }; AssertTargetDependsOnContainsAll(mainSource, "CiPublish", ciPublishDependencies, CiTargetOpenSpecGate, "build/Build.cs"); @@ -652,8 +665,10 @@ public void Ci_targets_enforce_openspec_strict_governance_gate() public void Continuous_transition_gate_enforces_lane_parity_for_closeout_critical_groups() { var repoRoot = FindRepoRoot(); + var combinedSource = ReadCombinedBuildSource(repoRoot); var mainSource = File.ReadAllText(Path.Combine(repoRoot, "build", "Build.cs")); - var ciDependsOn = ReadTargetDependsOnDependencies(mainSource, "Ci", TransitionGateParityConsistency, "build/Build.cs"); + var dependencyGraph = ReadTargetDependencyGraph(combinedSource); + var ciDependencyClosure = ReadTargetDependencyClosure(dependencyGraph, "Ci"); var ciPublishDependsOn = ReadTargetDependsOnDependencies(mainSource, "CiPublish", TransitionGateParityConsistency, "build/Build.cs"); var ciRequiredDependencies = new[] @@ -668,8 +683,8 @@ public void Continuous_transition_gate_enforces_lane_parity_for_closeout_critica foreach (var dep in ciRequiredDependencies) { Assert.True( - ciDependsOn.Contains(dep), - $"[{TransitionGateParityConsistency}] Missing Ci dependency '{dep}'."); + ciDependencyClosure.Contains(dep), + $"[{TransitionGateParityConsistency}] Missing Ci transitive dependency '{dep}'."); } Assert.True( @@ -678,42 +693,41 @@ public void Continuous_transition_gate_enforces_lane_parity_for_closeout_critica } [Fact] - public void Transition_gate_diagnostics_require_lane_and_expected_actual_fields() + public void Transition_gate_failures_use_governance_failure_schema() { const string artifactPath = "artifacts/test-results/transition-gate-governance-report.json"; using var reportDoc = JsonDocument.Parse( """ { "schemaVersion": 1, - "diagnostics": [ + "failures": [ { + "category": "release-closeout-snapshot", "invariantId": "GOV-024", - "lane": "Ci", - "artifactPath": "build/Build.cs", - "expected": "ReleaseCloseoutSnapshot", - "actual": "missing", - "group": "release-closeout-snapshot" + "sourceArtifact": "build/Build*.cs", + "expected": "Ci: dependency closure contains ReleaseCloseoutSnapshot", + "actual": "Ci: missing" } ] } """); - var diagnostics = RequireTransitionGateDiagnostics(reportDoc.RootElement, TransitionGateDiagnosticSchema, artifactPath); - Assert.Single(diagnostics.EnumerateArray()); - AssertTransitionGateDiagnostic(diagnostics.EnumerateArray().First(), TransitionGateDiagnosticSchema, artifactPath); + var failures = RequireReadinessFindingsArray(reportDoc.RootElement, "failures", TransitionGateDiagnosticSchema, artifactPath); + var failure = Assert.Single(failures.EnumerateArray()); + AssertDistributionReadinessFailure(failure, TransitionGateDiagnosticSchema, artifactPath); using var invalidDiagnosticDoc = JsonDocument.Parse( """ { "invariantId": "GOV-024", - "artifactPath": "build/Build.cs", - "expected": "Coverage", - "actual": "missing" + "sourceArtifact": "build/Build*.cs", + "expected": "Ci: dependency closure contains Coverage", + "actual": "Ci: missing" } """); Assert.Throws(() => - AssertTransitionGateDiagnostic(invalidDiagnosticDoc.RootElement, TransitionGateDiagnosticSchema, artifactPath)); + AssertDistributionReadinessFailure(invalidDiagnosticDoc.RootElement, TransitionGateDiagnosticSchema, artifactPath)); } [Fact] @@ -1182,7 +1196,8 @@ public void Bridge_distribution_governance_target_exists_in_cipublish_with_v2_pr { var repoRoot = FindRepoRoot(); var combinedSource = ReadCombinedBuildSource(repoRoot); - var mainSource = File.ReadAllText(Path.Combine(repoRoot, "build", "Build.cs")); + var dependencyGraph = ReadTargetDependencyGraph(combinedSource); + var ciDependencyClosure = ReadTargetDependencyClosure(dependencyGraph, "Ci"); AssertTargetDeclarationExists(combinedSource, "BridgeDistributionGovernance", BridgeDistributionParity, "build/Build.Governance.Distribution.cs"); AssertStringLiteralExists(combinedSource, "bridge-distribution-governance-report.json", BridgeDistributionParity, "build/Build*.cs"); @@ -1203,12 +1218,9 @@ public void Bridge_distribution_governance_target_exists_in_cipublish_with_v2_pr "build/Build*.cs"); AssertSourceContains(combinedSource, "{toolName} --version", BridgeDistributionParity, "build/Build*.cs"); - AssertTargetDependsOnContainsAll( - mainSource, - "Ci", - ["BridgeDistributionGovernance"], - BridgeDistributionParity, - "build/Build.cs"); + Assert.True( + ciDependencyClosure.Contains("BridgeDistributionGovernance"), + $"[{BridgeDistributionParity}] Missing Ci transitive dependency 'BridgeDistributionGovernance'."); } [Fact] @@ -1534,6 +1546,68 @@ private static string ReadCombinedBuildSource(string repoRoot) return string.Join("\n", buildFiles.Select(File.ReadAllText)); } + private static IReadOnlyDictionary> ReadTargetDependencyGraph(string source) + { + var matches = Regex.Matches( + source, + @"Target\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*=>[\s\S]*?\.DependsOn\((?[\s\S]*?)\);", + RegexOptions.Multiline); + + var graph = new Dictionary>(StringComparer.Ordinal); + foreach (Match match in matches) + { + var targetName = match.Groups["target"].Value; + var depsBlock = match.Groups["deps"].Value; + graph[targetName] = ParseDependencyList(depsBlock); + } + + return graph; + } + + private static IReadOnlySet ReadTargetDependencyClosure( + IReadOnlyDictionary> graph, + string targetName) + { + var closure = new HashSet(StringComparer.Ordinal); + var queue = new Queue(); + queue.Enqueue(targetName); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!graph.TryGetValue(current, out var dependencies)) + continue; + + foreach (var dependency in dependencies) + { + if (!closure.Add(dependency)) + continue; + + queue.Enqueue(dependency); + } + } + + return closure; + } + + private static IReadOnlySet ParseDependencyList(string dependsOnBlock) + { + var dependencies = new HashSet(StringComparer.Ordinal); + var segments = dependsOnBlock.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var segment in segments) + { + var token = segment.Trim(); + if (token.StartsWith("nameof(", StringComparison.Ordinal) && token.EndsWith(')')) + token = token[7..^1]; + + var identifierMatch = Regex.Match(token, @"([A-Za-z_][A-Za-z0-9_]*)$"); + if (identifierMatch.Success) + dependencies.Add(identifierMatch.Groups[1].Value); + } + + return dependencies; + } + private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory);