From 7be6d60ca9d66fbba7fd15aa5c711d9233374c4a Mon Sep 17 00:00:00 2001 From: Haoyang Liu Date: Fri, 24 Jul 2026 20:00:30 +0800 Subject: [PATCH 1/2] feat: harden nativeaot source generator coverage --- .github/scripts/check-coverage.sh | 142 +++++++++- .github/workflows/build-pr-ci.yml | 177 +++++++++++++ ROADMAP.md | 247 ++++++++---------- docs/architecture/language-features.md | 4 +- docs/architecture/source-generator.md | 2 + docs/en/architecture/language-features.md | 4 +- docs/en/architecture/source-generator.md | 2 + .../Emit/GeneratorDiagnostics.cs | 30 +++ .../Emit/NativeAotSignatureDiagnostic.cs | 65 +++++ .../Emit/ProxyEmitter.cs | 51 ++-- .../Properties/AssemblyInfo.cs | 8 + .../SourceGeneratorDiagnosticTests.cs | 177 +++++++++++++ .../NativeAotSourceGeneratedScenarios.cs | 216 +++++++++++++++ 13 files changed, 948 insertions(+), 177 deletions(-) create mode 100644 src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs create mode 100644 src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs diff --git a/.github/scripts/check-coverage.sh b/.github/scripts/check-coverage.sh index f4e03f43..cc14a7b3 100755 --- a/.github/scripts/check-coverage.sh +++ b/.github/scripts/check-coverage.sh @@ -11,14 +11,18 @@ export DOTNET_ROLL_FORWARD=Major readonly UNIT_TEST_MODE="unit" readonly E2E_TEST_MODE="e2e" +readonly NATIVEAOT_UNIT_TEST_MODE="nativeaot-unit" +readonly NATIVEAOT_E2E_TEST_MODE="nativeaot-e2e" readonly UT_THRESHOLD=95 readonly E2E_THRESHOLD=80 +readonly NATIVEAOT_UT_THRESHOLD=100 +readonly NATIVEAOT_E2E_THRESHOLD=95 usage() { cat >&2 < - $0 assert {${UNIT_TEST_MODE}|${E2E_TEST_MODE}} --input + $0 collect {${UNIT_TEST_MODE}|${E2E_TEST_MODE}|${NATIVEAOT_UNIT_TEST_MODE}|${NATIVEAOT_E2E_TEST_MODE}} --output + $0 assert {${UNIT_TEST_MODE}|${E2E_TEST_MODE}|${NATIVEAOT_UNIT_TEST_MODE}|${NATIVEAOT_E2E_TEST_MODE}} --input EOF exit 2 } @@ -33,24 +37,128 @@ mode_metadata() { "$E2E_TEST_MODE") printf '%s|%s|%s|%s\n' "E2E Test" "$E2E_THRESHOLD" "*E2E*.csproj" "" ;; + "$NATIVEAOT_UNIT_TEST_MODE") + printf '%s|%s|%s|%s\n' "NativeAOT Unit Test" "$NATIVEAOT_UT_THRESHOLD" "AspectCore.Core.Tests.csproj" "" + ;; + "$NATIVEAOT_E2E_TEST_MODE") + printf '%s|%s|%s|%s\n' "NativeAOT E2E Test" "$NATIVEAOT_E2E_THRESHOLD" "AspectCore.E2E.Tests.csproj" "" + ;; *) usage ;; esac } +nativeaot_mode() { + local mode="$1" + [[ "$mode" == "$NATIVEAOT_UNIT_TEST_MODE" || "$mode" == "$NATIVEAOT_E2E_TEST_MODE" ]] +} + +nativeaot_filter() { + local mode="$1" + + case "$mode" in + "$NATIVEAOT_UNIT_TEST_MODE") + printf '%s\n' "FullyQualifiedName~SourceGeneratorDiagnosticTests" + ;; + "$NATIVEAOT_E2E_TEST_MODE") + printf '%s\n' "FullyQualifiedName~NativeAotSourceGeneratedScenarios" + ;; + *) + printf '%s\n' "" + ;; + esac +} + +nativeaot_include_filter() { + local mode="$1" + + case "$mode" in + "$NATIVEAOT_UNIT_TEST_MODE") + printf '%s\n' "[AspectCore.SourceGenerator]*" + ;; + "$NATIVEAOT_E2E_TEST_MODE") + printf '%s\n' "[AspectCore.Core]*%2c[AspectCore.Abstractions]*" + ;; + *) + printf '%s\n' "" + ;; + esac +} + +target_framework() { + local mode="$1" + + case "$mode" in + "$NATIVEAOT_UNIT_TEST_MODE") + printf '%s\n' "net10.0" + ;; + *) + printf '%s\n' "net9.0" + ;; + esac +} + +nativeaot_scope_files() { + local mode="$1" + + case "$mode" in + "$NATIVEAOT_UNIT_TEST_MODE") + printf '%s\n' "Emit/NativeAotSignatureDiagnostic.cs" + ;; + "$NATIVEAOT_E2E_TEST_MODE") + printf '%s\n' \ + "AspectCore.Core/DynamicProxy/AspectContextFactory.cs" \ + "AspectCore.Core/DynamicProxy/SourceGeneratedAspectContext.cs" + ;; + esac +} + +coverage_from_cobertura() { + local coverage_file="$1" + shift + + python3 - "$coverage_file" "$@" <<'PY' +import sys +import xml.etree.ElementTree as ET + +coverage_file = sys.argv[1] +scope_files = set(sys.argv[2:]) +root = ET.parse(coverage_file).getroot() +covered = 0 +valid = 0 + +for cls in root.findall(".//class"): + filename = cls.attrib.get("filename", "") + if scope_files and filename not in scope_files: + continue + for line in cls.findall("./lines/line"): + valid += 1 + if int(line.attrib.get("hits", "0")) > 0: + covered += 1 + +if valid == 0: + print("0") +else: + print((covered * 100) // valid) +PY +} + run_coverage() { local project="$1" local label="$2" + local mode="$3" local project_dir local project_name local source_assembly local include_filter local coverage_file - local line_rate + local filter local coverage_pct local test_log local test_status + local dotnet_args + local framework echo "Measuring: $label..." >&2 @@ -69,13 +177,24 @@ run_coverage() { if [[ "$project_name" == *"E2E"* ]]; then include_filter="[AspectCore.Core]*%2c[AspectCore.Abstractions]*" fi + if nativeaot_mode "$mode"; then + include_filter=$(nativeaot_include_filter "$mode") + filter=$(nativeaot_filter "$mode") + else + filter="" + fi # Do not use --no-build: coverlet.msbuild needs to instrument assemblies. # Capture the pipeline status explicitly because this function's output is # consumed through command substitution by collect_coverage. set +e test_log=$(mktemp) - dotnet test "$project" --configuration Release -f net9.0 \ + framework=$(target_framework "$mode") + dotnet_args=("$project" --configuration Release -f "$framework") + if [[ -n "$filter" ]]; then + dotnet_args+=(--filter "$filter") + fi + dotnet test "${dotnet_args[@]}" \ /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura \ /p:CoverletOutput=./TestResults/ \ "/p:Include=${include_filter}" \ @@ -92,9 +211,16 @@ run_coverage() { coverage_file=$(find "$project_dir/TestResults" -name "*.cobertura.xml" -print -quit) if [[ -n "$coverage_file" && -f "$coverage_file" ]]; then - line_rate=$(grep -o 'line-rate="[^"]*"' "$coverage_file" | head -1 | cut -d'"' -f2) - if [[ -n "$line_rate" ]]; then - coverage_pct=$(echo "$line_rate * 100" | bc | cut -d. -f1) + if nativeaot_mode "$mode"; then + mapfile -t scope_files < <(nativeaot_scope_files "$mode") + coverage_pct=$(coverage_from_cobertura "$coverage_file" "${scope_files[@]}") + else + line_rate=$(grep -o 'line-rate="[^"]*"' "$coverage_file" | head -1 | cut -d'"' -f2) + if [[ -n "$line_rate" ]]; then + coverage_pct=$(echo "$line_rate * 100" | bc | cut -d. -f1) + fi + fi + if [[ -n "$coverage_pct" ]]; then echo " Coverage: ${coverage_pct}%" >&2 printf '%s\n' "$coverage_pct" return @@ -129,7 +255,7 @@ collect_coverage() { echo "=== ${heading} Coverage Collection ===" while IFS= read -r test_project; do - if ! coverage=$(run_coverage "$test_project" "$(basename "$(dirname "$test_project")")"); then + if ! coverage=$(run_coverage "$test_project" "$(basename "$(dirname "$test_project")")" "$mode"); then echo "FAIL: Test execution failed for ${test_project}." >&2 return 1 fi diff --git a/.github/workflows/build-pr-ci.yml b/.github/workflows/build-pr-ci.yml index 79c8862c..c9944016 100644 --- a/.github/workflows/build-pr-ci.yml +++ b/.github/workflows/build-pr-ci.yml @@ -349,6 +349,183 @@ jobs: output: { title: "E2E Test Coverage", summary }, }); + nativeaot-test-execution: + name: NativeAOT Test Execution + runs-on: ubuntu-latest + permissions: + checks: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 6.0.x + 8.0.x + 9.0.x + 10.0.x + - name: Collect NativeAOT unit coverage + id: unit_coverage + continue-on-error: true + shell: bash + run: | + chmod +x ./.github/scripts/check-coverage.sh + ./.github/scripts/check-coverage.sh collect nativeaot-unit --output coverage-results/nativeaot-unit.env + - name: Collect NativeAOT E2E coverage + id: e2e_coverage + continue-on-error: true + shell: bash + run: | + chmod +x ./.github/scripts/check-coverage.sh + ./.github/scripts/check-coverage.sh collect nativeaot-e2e --output coverage-results/nativeaot-e2e.env + - name: Publish NativeAOT execution coverage check + if: ${{ steps.unit_coverage.outcome == 'success' && steps.e2e_coverage.outcome == 'success' }} + uses: actions/github-script@v7 + env: + UNIT_COVERAGE: ${{ steps.unit_coverage.outputs.coverage }} + UNIT_THRESHOLD: ${{ steps.unit_coverage.outputs.threshold }} + E2E_COVERAGE: ${{ steps.e2e_coverage.outputs.coverage }} + E2E_THRESHOLD: ${{ steps.e2e_coverage.outputs.threshold }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const unitCoverage = process.env.UNIT_COVERAGE; + const unitThreshold = process.env.UNIT_THRESHOLD; + const e2eCoverage = process.env.E2E_COVERAGE; + const e2eThreshold = process.env.E2E_THRESHOLD; + const title = `NativeAOT Test: passed (UT ${unitCoverage}% / min ${unitThreshold}%, E2E ${e2eCoverage}% / min ${e2eThreshold}%)`; + const summary = [ + "## NativeAOT Test Execution", + "", + "NativeAOT focused unit and E2E coverage were collected successfully.", + "", + "| Scope | Current PR | Threshold |", + "| --- | --- | --- |", + `| Unit | ${unitCoverage}% | ${unitThreshold}% |`, + `| E2E | ${e2eCoverage}% | ${e2eThreshold}% |`, + ].join("\n"); + + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: title, + head_sha: context.payload.pull_request.head.sha, + status: "completed", + conclusion: "success", + output: { title: "NativeAOT Test Execution", summary }, + }); + - name: Fail when NativeAOT test execution failed + if: ${{ steps.unit_coverage.outcome != 'success' || steps.e2e_coverage.outcome != 'success' }} + shell: bash + run: exit 1 + - name: Upload NativeAOT coverage result + if: ${{ steps.unit_coverage.outcome == 'success' && steps.e2e_coverage.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: nativeaot-coverage-result + path: | + coverage-results/nativeaot-unit.env + coverage-results/nativeaot-e2e.env + if-no-files-found: error + retention-days: 1 + + nativeaot-coverage-result: + name: NativeAOT Coverage Result + needs: nativeaot-test-execution + if: ${{ always() }} + runs-on: ubuntu-latest + permissions: + checks: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Download NativeAOT coverage result + if: ${{ needs.nativeaot-test-execution.result == 'success' }} + uses: actions/download-artifact@v4 + with: + name: nativeaot-coverage-result + path: coverage-results + - name: Assert NativeAOT unit coverage + id: unit_coverage + if: ${{ needs.nativeaot-test-execution.result == 'success' }} + shell: bash + run: | + chmod +x ./.github/scripts/check-coverage.sh + ./.github/scripts/check-coverage.sh assert nativeaot-unit --input coverage-results/nativeaot-unit.env + - name: Assert NativeAOT E2E coverage + id: e2e_coverage + if: ${{ needs.nativeaot-test-execution.result == 'success' }} + shell: bash + run: | + chmod +x ./.github/scripts/check-coverage.sh + ./.github/scripts/check-coverage.sh assert nativeaot-e2e --input coverage-results/nativeaot-e2e.env + - name: Set NativeAOT execution failure result + id: execution_failure + if: ${{ needs.nativeaot-test-execution.result != 'success' }} + shell: bash + run: | + echo "unit_coverage=unavailable" >> "$GITHUB_OUTPUT" + echo "unit_threshold=100" >> "$GITHUB_OUTPUT" + echo "unit_passed=false" >> "$GITHUB_OUTPUT" + echo "e2e_coverage=unavailable" >> "$GITHUB_OUTPUT" + echo "e2e_threshold=95" >> "$GITHUB_OUTPUT" + echo "e2e_passed=false" >> "$GITHUB_OUTPUT" + - name: Publish NativeAOT coverage check + uses: actions/github-script@v7 + env: + EXECUTION_RESULT: ${{ needs.nativeaot-test-execution.result }} + UNIT_COVERAGE: ${{ steps.unit_coverage.outputs.coverage || steps.execution_failure.outputs.unit_coverage }} + UNIT_THRESHOLD: ${{ steps.unit_coverage.outputs.threshold || steps.execution_failure.outputs.unit_threshold }} + UNIT_PASSED: ${{ steps.unit_coverage.outputs.passed || steps.execution_failure.outputs.unit_passed }} + E2E_COVERAGE: ${{ steps.e2e_coverage.outputs.coverage || steps.execution_failure.outputs.e2e_coverage }} + E2E_THRESHOLD: ${{ steps.e2e_coverage.outputs.threshold || steps.execution_failure.outputs.e2e_threshold }} + E2E_PASSED: ${{ steps.e2e_coverage.outputs.passed || steps.execution_failure.outputs.e2e_passed }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const executionResult = process.env.EXECUTION_RESULT; + const unitCoverage = process.env.UNIT_COVERAGE; + const unitThreshold = process.env.UNIT_THRESHOLD; + const unitPassed = process.env.UNIT_PASSED === "true"; + const e2eCoverage = process.env.E2E_COVERAGE; + const e2eThreshold = process.env.E2E_THRESHOLD; + const e2ePassed = process.env.E2E_PASSED === "true"; + const unitAvailable = /^\d+$/.test(unitCoverage); + const e2eAvailable = /^\d+$/.test(e2eCoverage); + const conclusion = executionResult !== "success" + ? "failure" + : unitPassed && e2ePassed ? "success" : "failure"; + const result = conclusion === "success" ? "✅ Passed" : "❌ Failed"; + const title = unitAvailable && e2eAvailable + ? `NativeAOT Coverage: UT ${unitCoverage}% / min ${unitThreshold}%, E2E ${e2eCoverage}% / min ${e2eThreshold}%` + : `NativeAOT Coverage: unavailable / min UT ${unitThreshold}%, E2E ${e2eThreshold}%`; + const summary = [ + "## NativeAOT Coverage", + "", + "| Scope | Current PR | Threshold | Result |", + "| --- | --- | --- | --- |", + `| Unit | ${unitAvailable ? `${unitCoverage}%` : "Unavailable"} | ${unitThreshold}% | ${unitPassed ? "✅ Passed" : "❌ Failed"} |`, + `| E2E | ${e2eAvailable ? `${e2eCoverage}%` : "Unavailable"} | ${e2eThreshold}% | ${e2ePassed ? "✅ Passed" : "❌ Failed"} |`, + "", + `Overall result: ${result}`, + executionResult === "success" + ? "The coverage result was collected by the NativeAOT Test Execution job." + : `The NativeAOT Test Execution job completed with \`${executionResult}\`; no valid coverage result was produced.`, + ].join("\n"); + + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: title, + head_sha: context.payload.pull_request.head.sha, + status: "completed", + conclusion, + output: { title: "NativeAOT Coverage", summary }, + }); + codeql: runs-on: ubuntu-latest permissions: diff --git a/ROADMAP.md b/ROADMAP.md index cd2d1be2..42fb91ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,201 +1,158 @@ # AspectCore-Framework 发展路线图 -> 版本:2026-07-19 +> 版本:2026-07-24 > 维护者:AspectCore 核心团队 -> 本文档描述 AspectCore-Framework 的战略定位、优先级事项、时间线与风险评估。所有事项均按优先级排序,并附具体可执行的验收标准。 +> 本文档描述 AspectCore-Framework 的生态定位、已落地基线、下一阶段优先级与验收标准。 --- -## 一、战略定位 +## 一、生态定位 -### 1.1 项目概述 +AspectCore 不再把增长重点放在“更大的 IoC 容器”或“更复杂的 AOP 语法”上。项目的生态位是: -AspectCore 是一个开源的 .NET AOP(面向切面编程)框架,采用双引擎架构(运行时 DynamicProxy + 编译时 Source Generator),全面适配 C# 6–13 语言特性,并与 MSDI(Microsoft.Extensions.DependencyInjection)深度集成。 +> 在 Microsoft.Extensions 主航道上,提供 Castle 做不了、MSDI 不做、NativeAOT 时代又确实需要的 interception infrastructure。 -### 1.2 核心竞争优势 +这意味着后续投入优先服务四件事: -- **双引擎一致性**:同一套 `IAspectActivator`、`AspectContext` 和拦截器管道同时服务于运行时引擎与编译时引擎,用户无需为两种引擎维护两套拦截逻辑。 -- **激进的 C# 特性适配**:支持部分属性(partial properties)、主构造函数(primary constructors)、`params` 集合、`ref struct` 拒绝、记录类型(record types)、`ref`/`ref readonly` 返回、异步可枚举(async enumerable)等现代 C# 特性。 -- **统一的拦截器模型**:无需像 Castle 那样为异步场景单独维护 `IAsyncInterceptor` 包装器,一套拦截器模型覆盖同步与异步。 -- **深度 MSDI 集成 + Windsor 集成**:在 MSDI 无原生 AOP 的市场空白中占据有利位置,并提供 Windsor 集成以覆盖存量用户。 +1. **NativeAOT + Source Generator 体验扎实**:可发布、可运行、可诊断,失败尽量发生在编译期。 +2. **Castle / Windsor 迁移承接**:让存量 Castle 用户有低风险迁移路径,而不是只给一篇概念文档。 +3. **官方横切能力包**:围绕 OpenTelemetry、HttpClient、validation、cache、retry 等基础设施场景提供小而可靠的 interceptor 包。 +4. **工程化与信任资产**:benchmark、兼容矩阵、诊断目录、sample gallery、升级指南,让用户敢引入。 -### 1.3 核心短板 +--- -- **缺乏端到端 NativeAOT 支持**:`MethodReflector` 仍在运行时依赖 `DynamicMethod`,无法通过 NativeAOT 编译裁剪。 -- **Keyed 服务解析缺口**:`IServiceResolver` 的各实现对 `GetKeyedService`/`GetRequiredKeyedService` 抛出 `NotImplementedException`。 -- **Source Generator 仅为可选项**:编译时引擎未成为默认引擎,用户需手动启用。 +## 二、已落地基线 -### 1.4 竞争格局 +以下能力已从 roadmap 中移出,不再作为未来待办重复追踪: -| 竞品 | 现状 | 对 AspectCore 的影响 | -|------|------|----------------------| -| **MSDI** | 无原生 AOP;.NET 9 编译时 Source Generator 稳定,.NET 10 完全对等,正从默认 DI 管道中消除运行时 Reflection.Emit;.NET 8+ 原生装饰器正在侵蚀低端价值主张 | MSDI 不做 AOP 是 AspectCore 的核心机会;但需跟进其编译时方向,避免在运行时反射路线上被边缘化 | -| **Castle DynamicProxy** | Castle.Core 仍在积极维护(v5.2.1,2025-03),Windsor 功能冻结;16 亿次 NuGet 下载(多数经 Moq/NSubstitute/EF Core 传递依赖);技术局限:无 ref 返回、无 proper record 支持、无 NativeAOT、无 async streams、现代 C# 特性适配有限;Castle v6.0 Source Generator 已规划但无发布日期 | Castle 在存量市场仍占主导,但其技术天花板明显;AspectCore 可通过现代特性与 NativeAOT 形成差异化 | -| **Metalama** | 企业级编译时 AOP 最强竞品,附带 IDE 工具;免费增值模式,核心闭源 | 在高端企业市场构成直接竞争;AspectCore 以"免费 + 开源"差异化 | -| **AspectInjector** | 基于 Roslyn 的编译时织入器,MIT 协议,约 660 万下载;无官方 NativeAOT 支持 | 同属开源编译时方案,AspectCore 需以双引擎一致性和更广泛的特性适配拉开差距 | +- **Keyed 服务解析与拦截**:`IServiceResolver` 在 .NET 8+ 接入 `IKeyedServiceProvider`,内置容器、MSDI、Autofac、Windsor、LightInject 均已有 keyed 解析实现;keyed singleton/transient、多 key、缺失 required、keyed + non-keyed 混用、DynamicProxy + Source Generator 双引擎测试已覆盖。 +- **Source Generator 引擎选择基础设施**:`ProxyEngine.DynamicProxy` / `SourceGenerator` / `Auto`、`AllowRuntimeFallback`、`Strict` 已存在。默认仍保持 `DynamicProxy`,避免破坏现有用户升级行为。 +- **Source Generator NativeAOT 主路径**:已有 `IAspectInvokeDelegate`、`SourceGeneratedAspectContext`、NativeAOT E2E 工程和 `nativeaot-verify` workflow。当前承诺范围是 Source Generator 路径,不承诺 DynamicProxy 在 NativeAOT 下可用。 +- **Castle 兼容垫片与迁移文档雏形**:已有 `AspectCore.Extensions.CastleCompat`、迁移指南、功能对照和 checklist。仍缺自动化迁移工具。 +- **竞品 benchmark 雏形**:已有 `benchmarks/AspectCore.Benchmarks.Competitive`,覆盖 Castle 对比的首次调用、稳态、内存、NativeAOT 兼容性说明。仍缺结果发布与门禁策略。 --- -## 二、短期优先级(1–2 周) - -### P0-1:修复 Keyed 服务解析缺口 - -**背景**:`IServiceResolver` 的各实现对 `GetKeyedService`/`GetRequiredKeyedService` 抛出 `NotImplementedException`。该缺口已被作为"预期行为"写入测试,严重损害用户信任。.NET 8 起 MSDI 原生支持 keyed 服务,AspectCore 必须跟进。 +## 三、优先级路线 -**具体行动**: -1. 审计所有 `IServiceResolver` 实现类,列出缺失 `GetKeyedService`/`GetRequiredKeyedService` 的清单。 -2. 逐一实现两个方法,确保与 MSDI 的 `IKeyedServiceProvider` 语义对齐: - - `GetKeyedService(Type serviceType, object? serviceKey)` —— keyed 服务不存在时返回 `null`。 - - `GetRequiredKeyedService(Type serviceType, object? serviceKey)` —— keyed 服务不存在时抛出明确异常。 -3. 确保 keyed 服务解析路径同样经过切面激活管道(不绕过拦截器)。 +### P0:NativeAOT + Source Generator 体验扎实 -**验收标准**: -- [x] 所有 `IServiceResolver` 实现不再包含 `NotImplementedException`。 -- [x] keyed 服务解析与普通服务解析走同一套切面激活逻辑。 -- [x] 现有"预期行为"测试更新为真实行为断言。 +**目标**:让用户能用 CLI / template / sample 快速跑起 Source Generator NativeAOT,并在不支持的签名、缺失生成物或 fallback 场景下得到明确诊断。 -### P0-2:新增 Keyed 服务拦截集成测试 +**当前边界**: -**背景**:P0-1 修复后,需要端到端测试验证 keyed 服务在被拦截时的完整行为,防止回归。 +- 支持目标是 **Source Generator NativeAOT**。 +- DynamicProxy 仍依赖 `Reflection.Emit` / `DynamicMethod`,在 NativeAOT 下不可用。 +- 开放泛型、byref-like、反射 metadata、manual registry 等边界必须以诊断和文档明确呈现。 **具体行动**: -1. 新增集成测试项目(或扩充现有集成测试),覆盖以下场景: - - keyed 单例服务的拦截器执行。 - - keyed 瞬态服务的拦截器执行。 - - 同一接口注册多个 keyed 实现时,按 key 精确解析并拦截。 - - `GetRequiredKeyedService` 在服务缺失时抛出预期异常。 - - keyed 服务与非 keyed 服务混合注册时的解析正确性。 -2. 测试需同时覆盖运行时 DynamicProxy 引擎与编译时 Source Generator 引擎(双引擎一致性验证)。 + +1. 完善 Source Generator 编译期诊断: + - byref-like 代理目标继续报 ACSG008。 + - byref-like `params` 参数继续报 ACSG009。 + - 非 `params` byref-like 参数新增专门诊断,避免生成后在 `object[]` 参数管道中失败。 + - byref-like 返回值新增专门诊断,避免生成后在 `object ReturnValue` 管道中失败。 + - 开放泛型方法的 NativeAOT fallback 继续提示 `[AspectCoreGenericHint]`。 +2. 增强 NativeAOT 可运行体验: + - 保持 `tests/AspectCore.NativeAot.E2E` 作为最小可发布证明。 + - 提供用户可复制的最小配置片段:analyzer 引用、`ProxyEngine.SourceGenerator` / `Strict`、manual registry、`PublishAot`。 + - 给 `dotnet publish` 失败场景补 troubleshooting:缺 registry、缺 metadata、命中 DynamicProxy fallback、byref-like 签名。 +3. 建立诊断目录: + - 每个 `ACSGxxx` 诊断都有原因、触发示例、修复建议、是否影响 NativeAOT 的说明。 + - 中英文文档保持一致。 +4. 保持 CI 证明: + - NativeAOT workflow 必须 publish + run。 + - Source Generator 诊断测试必须在 `AspectCore.Core.Tests` 中覆盖。 + - NativeAOT 专用覆盖率门禁独立于全仓普通覆盖率:unit scope 不低于 100%,E2E scope 不低于 95%。 **验收标准**: -- [x] 上述场景均有对应测试用例并全部通过。 -- [x] 测试在 CI 中作为门禁,keyed 相关变更必须通过全部用例。 -### P1-1:将 Source Generator 设为默认引擎并提供 Auto 回退 +- NativeAOT E2E publish/run 稳定通过。 +- 常见不可 AOT/不可 SG 的签名在编译期给出 `ACSGxxx` 诊断,而不是运行时崩溃。 +- NativeAOT unit coverage 达到 100%,NativeAOT E2E coverage 达到 95%。 +- 用户文档能从零配置到可运行 NativeAOT 示例。 +- 文档明确区分 Source Generator NativeAOT 与 DynamicProxy 非 AOT。 -**背景**:Source Generator 目前仅为可选项,多数用户仍在使用运行时 DynamicProxy。将编译时引擎设为默认,可显著降低运行时开销、改善 NativeAOT 兼容性,并与 MSDI 的编译时方向保持一致。 +### P1:Castle / Windsor 迁移承接 + +**目标**:承接 Castle DynamicProxy / Windsor 存量用户,把 AspectCore 做成现代 C# 与 AOT 迁移路径,而不是另一个并列框架。 **具体行动**: -1. 引入引擎选择策略:`Auto`(默认)、`Runtime`、`SourceGenerator`。 -2. `Auto` 模式行为: - - 优先使用 Source Generator(若目标项目已启用并成功生成代理)。 - - 当 Source Generator 不可用(如项目未启用、生成失败、或目标框架不支持)时,自动回退到运行时 DynamicProxy。 - - 回退时记录可诊断的警告信息,便于用户排查。 -3. 更新文档与示例,将默认配置指向 `Auto`。 -4. 提供显式选择 `Runtime`/`SourceGenerator` 的配置入口,满足需要确定性行为的场景。 + +1. 补齐 `AspectCore.Extensions.CastleCompat` 的 solution 体验、包说明和示例。 +2. 提供 Roslyn analyzer 或 CLI 工具,扫描: + - `Castle.DynamicProxy.IInterceptor` / `IInvocation` 使用点。 + - Windsor `Component.For().ImplementedBy().Interceptors()` 注册链。 + - Castle-specific lifecycle、selector、mixin、`IChangeProxyTarget` 等不可迁移点。 +3. 输出机器生成的迁移 checklist: + - 可直接迁移项。 + - 需要人工判断项。 + - 不支持项与替代建议。 +4. 提供一组可运行 before/after sample。 **验收标准**: -- 新用户零配置即获得 Source Generator 优先的体验。 -- `Auto` 回退路径有明确日志,不静默降级。 -- 现有运行时用户升级后行为不变(回退到 Runtime)。 ---- +- 示例 Windsor 项目能生成可操作迁移报告。 +- 迁移报告覆盖 interceptor API、容器注册、生命周期、selector、unsupported feature。 +- Castle 兼容垫片有完整测试,并被 CI build/test 覆盖。 -## 三、中期优先级(1–3 个月) +### P2:官方横切能力包 -### P0-3:实现端到端 NativeAOT 支持 +**目标**:降低用户第一次使用成本,但不把 AspectCore 变成业务框架。 -**背景**:`MethodReflector` 在运行时依赖 `DynamicMethod`(Reflection.Emit),这是 NativeAOT 的硬性障碍。若 12–18 个月内无法提供端到端 NativeAOT 支持,AspectCore 将与平台方向不兼容,面临被淘汰的最高风险。 +**建议包范围**: -**具体行动**: -1. 梳理 `MethodReflector` 中所有 `DynamicMethod` 使用点,明确其在拦截器管道中的职责(方法调用分派、参数构造、返回值提取等)。 -2. 以编译时生成的分派代码(compile-time-generated dispatch)替代运行时 `DynamicMethod`: - - Source Generator 为被拦截方法生成强类型分派逻辑,在编译期完成方法签名的绑定。 - - 运行时引擎在 NativeAOT 场景下走预生成的分派表,完全避免 `DynamicMethod`。 -3. 标注所有仍依赖运行时反射的类型与成员,添加 `[RequiresDynamicCode]` / `[RequiresUnreferencedCode]` 注解,使 NativeAOT 编译警告可定位、可消除。 -4. 建立 NativeAOT 验证工程: - - 配置 `true` 的示例项目。 - - 在 CI 中增加 NativeAOT 发布 + 运行验证流水线。 +- `AspectCore.Extensions.OpenTelemetry`:method span、exception tags、duration metrics。 +- `AspectCore.Extensions.HttpClient`:出站调用 trace context / audit hooks。 +- `AspectCore.Extensions.Validation`:DataAnnotations / FluentValidation 接入。 +- `AspectCore.Extensions.Caching`:`IMemoryCache` / `IDistributedCache` 接入。 +- `AspectCore.Extensions.Resilience`:基于 Polly 的 retry / timeout / circuit breaker。 -**验收标准**: -- NativeAOT 发布的示例项目可编译、可运行,且拦截器行为与运行时版本一致。 -- `MethodReflector` 不再在 NativeAOT 路径上调用 `DynamicMethod`。 -- CI 中 NativeAOT 流水线稳定通过。 +**约束**: -### P1-2:构建 Windsor 迁移指南与工具 +- 优先复用 OpenTelemetry、Polly、Microsoft.Extensions.*,不自造标准。 +- 每个包必须有最小 sample、行为测试、性能边界说明。 +- 不把 transaction / ORM 等高风险语义作为第一批官方包。 -**背景**:Windsor 功能冻结,Castle.Core 虽在维护但技术天花板明显。大量存量 Windsor/Castle 用户正在寻找迁移路径。AspectCore 提供兼容垫片与迁移工具,可有效承接这部分用户。 +### P3:工程化与信任资产 -**具体行动**: -1. 编写 Castle `IInterceptor`/`IInvocation` API 到 AspectCore 拦截器模型的兼容性垫片(compatibility shim): - - 使 Castle 风格的拦截器可在 AspectCore 管道中运行,降低迁移初期改造成本。 - - 明确标注垫片的覆盖范围与不支持的 API(如 ref 返回、async streams 等 Castle 不支持的特性)。 -2. 撰写《Windsor → AspectCore 迁移指南》: - - 容器注册方式对照。 - - 拦截器 API 对照与自动转换建议。 - - 常见配置(命名约定、属性注入、生命周期)的迁移步骤。 - - FAQ:迁移后行为差异、性能影响、如何逐步迁移而非一次性切换。 -3. 提供迁移辅助工具(脚本或 Roslyn 分析器): - - 识别 Windsor 特有 API 调用并给出 AspectCore 替代建议。 - - 生成迁移检查清单。 - -**验收标准**: -- 兼容垫片覆盖 Castle `IInterceptor`/`IInvocation` 的核心使用场景。 -- 迁移指南包含可运行的前后对比示例。 -- 迁移工具可在示例 Windsor 项目上输出可操作的迁移建议。 - -### P1-3:建立对标竞品的基准测试套件 - -**背景**:需要用数据说话,量化 AspectCore 相对于 Castle DynamicProxy、Metalama、AspectInjector 的性能表现,为技术决策与市场宣传提供依据。 +**目标**:让用户能判断 AspectCore 是否适合生产引入。 **具体行动**: -1. 定义基准测试维度: - - 首次调用延迟(first-invocation latency):代理创建 + 首次拦截的端到端耗时。 - - 稳态开销(steady-state overhead):拦截器在热路径上的额外耗时。 - - 内存分配(memory allocation):每次拦截的分配字节数与 GC 压力。 - - NativeAOT 兼容性:是否可发布为 NativeAOT 及发布后体积。 -2. 选定对标对象:Castle DynamicProxy(v5.x)、AspectInjector(Metalama 闭源,可做定性对比)。 -3. 使用 BenchmarkDotNet 构建测试套件,确保: - - 每个维度有明确的测试方法与对照组(无拦截基线)。 - - 测试环境与参数(框架版本、硬件、并发数)文档化。 - - 结果可复现。 -4. 定期(如每季度)更新基准结果并公开发布。 -**验收标准**: -- 基准套件覆盖上述四个维度,每个维度至少有一个 Castle 对标用例。 -- 基准结果以表格 + 图表形式发布在 docs 目录。 -- 基准测试纳入 CI,防止性能回归。 +1. Benchmark dashboard: + - AspectCore DynamicProxy / Source Generator / Castle 长期对比。 + - 首次调用、稳态、分配、NativeAOT 体积和启动耗时。 + - 结果发布到 docs,CI 至少保证 benchmark 能编译。 +2. Compatibility matrix: + - TFM、C# 特性、容器、Source Generator、NativeAOT、trim 的支持表。 +3. Diagnostic catalog: + - `ACSGxxx` 诊断目录与修复建议。 +4. Sample gallery: + - Web API、Worker、Aspire、NativeAOT CLI、OpenTelemetry、Castle migration。 +5. Upgrade guide: + - 2.x 到 3.x 的行为边界、Source Generator opt-in、AOT 限制。 --- -## 四、风险评估(1–2 年) +## 四、风险评估 | 风险 | 等级 | 说明 | 缓解策略 | |------|------|------|----------| -| **NativeAOT 过时风险** | 最高 | 若 12–18 个月内无法提供端到端 NativeAOT 支持,将与平台方向不兼容 | P0-3 为最高优先级;在 NativeAOT 路径上彻底移除 `DynamicMethod`;建立 NativeAOT CI 门禁 | -| **Metalama 免费层扩张** | 高 | Metalama 若扩大免费层功能,将侵蚀 AspectCore"免费 + 开源"的差异化优势 | 强化开源社区治理、双引擎一致性、与 MSDI 深度集成等 Metalama 不具备的优势;保持完全开源 | -| **Castle Source Generator 发布** | 中 | Castle v6.0 若发布 Source Generator,将缩小 AspectCore 在编译时方向的差距 | 加快 P1-1(Source Generator 默认化)与 P0-3(NativeAOT)落地,在 Castle 之前占据编译时 + NativeAOT 的生态位 | -| **社区可持续性** | 中 | 团队规模小,节奏激进,存在 burnout 风险 | 建立贡献者指南与自动化 CI/CD 降低维护成本;优先保障 P0 事项,P1 事项可吸纳社区贡献;避免在非核心方向过度消耗 | -| **Keyed 服务缺口损害信任** | 中 | `NotImplementedException` 被作为"预期行为"测试,传递出"功能不完整"的负面信号 | P0-1 + P0-2 立即修复;修复后发布公告,主动恢复用户信心 | +| NativeAOT 体验不可信 | 最高 | 只要用户遇到运行时崩溃或不可解释的 publish failure,就很难相信 Source Generator 路径 | 把失败前移到 `ACSGxxx` 诊断;NativeAOT E2E 必须 publish + run;明确 DynamicProxy 非 AOT | +| 迁移成本过高 | 高 | Castle/Windsor 用户不是因为框架名迁移,而是因为 AOT、性能和现代 C# 需求迁移 | analyzer/CLI 输出迁移报告;保留兼容垫片;提供 before/after sample | +| 横切包范围失控 | 中 | 官方包过多会变成维护负担,并把项目拖向应用框架 | 第一批只做基础设施包;复用成熟生态;高风险语义延后 | +| benchmark 不可复现 | 中 | 宣传性能但无可复现结果会损害信任 | BenchmarkDotNet 工程、环境说明、结果归档、CI 编译门禁 | --- -## 五、时间线总览 - -| 阶段 | 时间窗口 | 事项 | 优先级 | -|------|----------|------|--------| -| **短期** | 第 1–2 周 | P0-1:修复 keyed 服务解析缺口 | P0 | -| | | P0-2:keyed 服务拦截集成测试 | P0 | -| | | P1-1:Source Generator 默认引擎 + Auto 回退 | P1 | -| **中期** | 第 1–3 个月 | P0-3:端到端 NativeAOT 支持 | P0 | -| | | P1-2:Windsor 迁移指南与工具 | P1 | -| | | P1-3:对标竞品基准测试套件 | P1 | -| **长期** | 第 3–12 个月 | 持续跟进 C# 最新特性适配 | P2 | -| | | 扩展生态集成(更多 DI 容器、框架) | P2 | -| | | 社区治理与贡献者体系建设 | P2 | - -> **优先级说明**:P0 = 必须立即完成,阻塞核心竞争力或损害用户信任;P1 = 应在中期内完成,强化差异化优势;P2 = 长期投入,视社区资源与市场反馈动态调整。 - ---- - -## 六、优先级决策原则 - -当资源有限时,按以下顺序取舍: +## 五、决策原则 -1. **用户信任优先**:已暴露的功能缺口(如 keyed 服务)必须先于新特性修复。 -2. **平台方向优先**:NativeAOT 是 .NET 平台的确定性方向,与之兼容是生存前提。 -3. **差异化优先**:选择能放大 AspectCore 独特优势(双引擎一致性、现代 C# 适配、开源免费)的投入。 -4. **可验证优先**:每项工作必须有明确的验收标准与 CI 门禁,避免"完成了但无法证明"。 +1. **平台方向优先**:NativeAOT / trimming / Source Generator 是主线。 +2. **迁移价值优先**:优先承接已有 Castle/Windsor 用户的真实迁移成本。 +3. **生态复用优先**:OpenTelemetry、Polly、Microsoft.Extensions.* 这类成熟标准优先,不自造协议。 +4. **可诊断优先**:无法支持的场景必须有清晰诊断、文档和替代路径。 +5. **兼容优先**:默认运行时行为不轻易改变;Source Generator / NativeAOT 以显式 opt-in 和 `Strict` 模式推进。 --- diff --git a/docs/architecture/language-features.md b/docs/architecture/language-features.md index 640dc25c..81232f35 100644 --- a/docs/architecture/language-features.md +++ b/docs/architecture/language-features.md @@ -155,7 +155,7 @@ ref struct(如 `Span`、`ReadOnlySpan`)有 CLR 强制限制:不能 - 只有**未命中拦截的直连路径**能正确传递 ref struct 参数/返回值(IL 直接透传、SG 直接调用)。 - 一旦方法进入**拦截路径**,两套引擎都会把参数装进 `object[]`:DynamicProxy 的 `EmitInitializeMetaData` 对每个参数 `EmitConvertToObject`(`ILEmitVisitor.cs`),SG 的 `EmitArgumentsArray` 生成 `new object[]{...}`(`ProxyEmitter.cs`)。ref struct 无法装箱到 `object`,因此运行时会抛 `InvalidProgramException`(例如对带拦截器的 `int Length(Span)` 实测即抛此异常)。 -- SG 目前只对 `params` 的 byref-like 参数显式报告 ACSG009(见 3.2.2);非 `params` 的 `Span` 参数在被拦截方法里没有专门诊断,会在运行时失败。 +- SG 会在生成阶段提前诊断:`params` byref-like 参数报告 ACSG009,普通 byref-like 参数报告 ACSG010,byref-like 返回值报告 ACSG011。这样 Source Generator / NativeAOT 路径不会把这些签名拖到运行时失败。 **结论**:ref struct 代理目标已被安全拒绝;但「被拦截方法的 byref-like 参数/返回值」尚未支持,是已知限制。 @@ -679,7 +679,7 @@ P0(已全部完成) P1(功能缺失) P2(注解/低 **现状**:ref struct 不能装箱、不能作为类字段。分两种场景: - **代理目标类型**:✅ 已拒绝——入口检查 `type.IsByRefLike`(DynamicProxy)/ 报 ACSG008(SG),提前报错。 -- **被拦截方法的 byref-like 参数/返回值**:❌ 仍不支持——拦截路径把参数装进 `object[]`,`Span` 等无法装箱,运行时抛 `InvalidProgramException`;仅未拦截的直连路径可透传。详见 3.1.5。 +- **被拦截方法的 byref-like 参数/返回值**:❌ 仍不支持——拦截路径把参数装进 `object[]`,`Span` 等无法装箱;SG 路径提前报告 ACSG009/ACSG010/ACSG011,仅未拦截的直连路径可透传。详见 3.1.5。 ### 6.6 ✅ ref 返回方法 — 已适配 diff --git a/docs/architecture/source-generator.md b/docs/architecture/source-generator.md index 745c31f4..1e4ef17f 100644 --- a/docs/architecture/source-generator.md +++ b/docs/architecture/source-generator.md @@ -40,6 +40,8 @@ Source Generator 是 AspectCore 的编译时代理引擎,基于 Roslyn 增量 | ACSG007 | Error | 类代理缺少可访问构造函数 | | ACSG008 | Error | 无法代理 `ref struct` | | ACSG009 | Warning | `params` 的 byref-like 参数不支持 | +| ACSG010 | Warning | byref-like 参数不支持 | +| ACSG011 | Warning | byref-like 返回值不支持 | (ACSG001/ACSG004 为历史保留的「开放泛型类型/方法不支持」描述符;当前泛型已支持,不再主动触发。诊断标题/消息为中文。) diff --git a/docs/en/architecture/language-features.md b/docs/en/architecture/language-features.md index 4d615f01..26993de2 100644 --- a/docs/en/architecture/language-features.md +++ b/docs/en/architecture/language-features.md @@ -155,7 +155,7 @@ ref structs (such as `Span`, `ReadOnlySpan`) have CLR-enforced restriction - Only the **non-intercepted direct-call path** passes ref struct parameters/return values correctly (IL direct pass-through, or the SG direct call). - Once a method enters the **interception path**, both engines pack the arguments into `object[]`: DynamicProxy's `EmitInitializeMetaData` calls `EmitConvertToObject` on each parameter (`ILEmitVisitor.cs`), and SG's `EmitArgumentsArray` emits `new object[]{...}` (`ProxyEmitter.cs`). A ref struct cannot be boxed to `object`, so it throws `InvalidProgramException` at runtime (for example, an intercepted `int Length(Span)` throws exactly this). -- SG currently reports ACSG009 only for byref-like `params` parameters (see 3.2.2); a non-`params` `Span` parameter on an intercepted method has no dedicated diagnostic and fails at runtime. +- SG now diagnoses these signatures during generation: byref-like `params` parameters report ACSG009, ordinary byref-like parameters report ACSG010, and byref-like return values report ACSG011. This keeps the Source Generator / NativeAOT path from deferring those failures to runtime. **Conclusion**: ref struct proxy targets are safely rejected; but "byref-like parameters/return values on an intercepted method" is not yet supported — a known limitation. @@ -679,7 +679,7 @@ P0 (all done) P1 (missing feature) P2 (annotations/low-pri) **Status**: a ref struct cannot be boxed and cannot be a class field. Two scenarios: - **Proxy target type**: ✅ rejected — the entry checks `type.IsByRefLike` (DynamicProxy) / reports ACSG008 (SG), failing early. -- **byref-like parameters/return values on an intercepted method**: ❌ still unsupported — the interception path packs arguments into `object[]`, and a `Span` cannot be boxed, throwing `InvalidProgramException` at runtime; only the non-intercepted direct-call path passes them through. See 3.1.5. +- **byref-like parameters/return values on an intercepted method**: ❌ still unsupported — the interception path packs arguments into `object[]`, and a `Span` cannot be boxed; the SG path reports ACSG009/ACSG010/ACSG011 early, and only the non-intercepted direct-call path passes them through. See 3.1.5. ### 6.6 ✅ ref return methods — Adapted diff --git a/docs/en/architecture/source-generator.md b/docs/en/architecture/source-generator.md index 0c0c4543..8f74f1e4 100644 --- a/docs/en/architecture/source-generator.md +++ b/docs/en/architecture/source-generator.md @@ -40,6 +40,8 @@ The generator reports diagnostics when it encounters unsupported situations (`Em | ACSG007 | Error | A class proxy lacks an accessible constructor | | ACSG008 | Error | Cannot proxy a `ref struct` | | ACSG009 | Warning | A byref-like `params` parameter is not supported | +| ACSG010 | Warning | A byref-like parameter is not supported | +| ACSG011 | Warning | A byref-like return value is not supported | (ACSG001/ACSG004 are historically retained descriptors for "open generic type/method not supported"; generics are now supported and these are no longer actively triggered. The diagnostic titles/messages are in Chinese.) diff --git a/src/AspectCore.SourceGenerator/Emit/GeneratorDiagnostics.cs b/src/AspectCore.SourceGenerator/Emit/GeneratorDiagnostics.cs index d42bf87f..e5f973ae 100644 --- a/src/AspectCore.SourceGenerator/Emit/GeneratorDiagnostics.cs +++ b/src/AspectCore.SourceGenerator/Emit/GeneratorDiagnostics.cs @@ -77,6 +77,22 @@ internal static class GeneratorDiagnostics defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor UnsupportedByRefLikeParameterDescriptor = new( + id: "ACSG010", + title: "AspectCore SourceGenerator 暂不支持 byref-like 参数", + messageFormat: "成员 '{0}' 包含 byref-like 参数 '{1}',当前版本的 Source Generator 暂不支持生成代理。byref-like 类型(如 Span、ReadOnlySpan)无法进入 AspectCore 的 object[] 参数管道。", + category: "AspectCore.SourceGenerator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnsupportedByRefLikeReturnDescriptor = new( + id: "ACSG011", + title: "AspectCore SourceGenerator 暂不支持 byref-like 返回值", + messageFormat: "成员 '{0}' 返回 byref-like 类型 '{1}',当前版本的 Source Generator 暂不支持生成代理。byref-like 类型(如 Span、ReadOnlySpan)无法进入 AspectCore 的 object ReturnValue 管道。", + category: "AspectCore.SourceGenerator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + private static readonly DiagnosticDescriptor OpenGenericMethodNativeAotFallbackDescriptor = new( id: "ACSG0101", title: "Open generic method falls back to reflection for NativeAOT", @@ -116,6 +132,20 @@ public static Diagnostic UnsupportedByRefLikeParams(IMethodSymbol method, IParam method.ToDisplayString(), parameter.Name); + public static Diagnostic UnsupportedByRefLikeParameter(IMethodSymbol method, IParameterSymbol parameter) + => Diagnostic.Create( + UnsupportedByRefLikeParameterDescriptor, + parameter.Locations.FirstOrDefault() ?? method.Locations.FirstOrDefault(), + method.ToDisplayString(), + parameter.Name); + + public static Diagnostic UnsupportedByRefLikeReturn(IMethodSymbol method) + => Diagnostic.Create( + UnsupportedByRefLikeReturnDescriptor, + method.Locations.FirstOrDefault(), + method.ToDisplayString(), + method.ReturnType.ToDisplayString()); + public static Diagnostic OpenGenericMethodNativeAotFallback(INamedTypeSymbol type, IMethodSymbol method) => Diagnostic.Create( OpenGenericMethodNativeAotFallbackDescriptor, diff --git a/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs b/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs new file mode 100644 index 00000000..ee36a5e5 --- /dev/null +++ b/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs @@ -0,0 +1,65 @@ +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace AspectCore.SourceGenerator; + +internal enum NativeAotSignatureDiagnosticKind +{ + None, + ByRefLikeParamsParameter, + ByRefLikeParameter, + ByRefLikeReturn, +} + +internal readonly struct NativeAotSignatureDiagnostic +{ + public NativeAotSignatureDiagnostic(NativeAotSignatureDiagnosticKind kind, IMethodSymbol method, IParameterSymbol? parameter) + { + Kind = kind; + Method = method; + Parameter = parameter; + } + + public NativeAotSignatureDiagnosticKind Kind { get; } + + public IMethodSymbol Method { get; } + + public IParameterSymbol? Parameter { get; } + + public bool HasDiagnostic => Kind != NativeAotSignatureDiagnosticKind.None; +} + +internal static class NativeAotSignatureDiagnosticRules +{ + public static NativeAotSignatureDiagnostic Analyze(IMethodSymbol method) + { + if (!method.ReturnsVoid && IsByRefLikeType(method.ReturnType)) + { + return new NativeAotSignatureDiagnostic( + NativeAotSignatureDiagnosticKind.ByRefLikeReturn, + method, + parameter: null); + } + + var parameter = method.Parameters.FirstOrDefault(p => IsByRefLikeType(p.Type)); + if (parameter is null) + { + return new NativeAotSignatureDiagnostic( + NativeAotSignatureDiagnosticKind.None, + method, + parameter: null); + } + + return new NativeAotSignatureDiagnostic( + parameter.IsParams + ? NativeAotSignatureDiagnosticKind.ByRefLikeParamsParameter + : NativeAotSignatureDiagnosticKind.ByRefLikeParameter, + method, + parameter); + } + + private static bool IsByRefLikeType(ITypeSymbol type) + { + return type is INamedTypeSymbol { IsRefLikeType: true }; + } +} diff --git a/src/AspectCore.SourceGenerator/Emit/ProxyEmitter.cs b/src/AspectCore.SourceGenerator/Emit/ProxyEmitter.cs index c504b575..b8e9cca5 100644 --- a/src/AspectCore.SourceGenerator/Emit/ProxyEmitter.cs +++ b/src/AspectCore.SourceGenerator/Emit/ProxyEmitter.cs @@ -28,7 +28,7 @@ internal static class ProxyEmitter // Generic methods are supported (proxy method emits generic arity + MakeGenericMethod). - if (TryReportUnsupportedByRefLikeParams(entry.ServiceType, context)) + if (TryReportUnsupportedByRefLikeMembers(entry.ServiceType, context)) { return null; } @@ -126,7 +126,7 @@ internal static class ProxyEmitter // Generic methods are supported (proxy method emits generic arity + MakeGenericMethod). // Generic classes are also supported: type parameters are forwarded to the proxy. - if (TryReportUnsupportedByRefLikeParams(entry.ServiceType, context)) + if (TryReportUnsupportedByRefLikeMembers(entry.ServiceType, context)) { return null; } @@ -1565,36 +1565,52 @@ private static void EmitConstructorAttributes(StringBuilder sb, IMethodSymbol ct } } - private static bool TryReportUnsupportedByRefLikeParams(INamedTypeSymbol serviceType, SourceProductionContext context) + private static bool TryReportUnsupportedByRefLikeMembers(INamedTypeSymbol serviceType, SourceProductionContext context) { foreach (var method in GetProxyableMethods(serviceType).Concat(GetProxyablePropertyAccessors(serviceType))) { - var parameter = method.Parameters.FirstOrDefault(p => p.IsParams && IsByRefLikeType(p.Type)); - if (parameter is null) + if (TryReportUnsupportedByRefLikeMethodMember(method, context)) { - continue; + return true; } - - context.ReportDiagnostic(GeneratorDiagnostics.UnsupportedByRefLikeParams(method, parameter)); - return true; } foreach (var constructor in serviceType.InstanceConstructors.Where(c => c.DeclaredAccessibility is Accessibility.Public or Accessibility.Protected or Accessibility.ProtectedOrInternal or Accessibility.ProtectedAndInternal)) { - var parameter = constructor.Parameters.FirstOrDefault(p => p.IsParams && IsByRefLikeType(p.Type)); - if (parameter is null) + if (TryReportUnsupportedByRefLikeMethodMember(constructor, context)) { - continue; + return true; } - - context.ReportDiagnostic(GeneratorDiagnostics.UnsupportedByRefLikeParams(constructor, parameter)); - return true; } return false; } + private static bool TryReportUnsupportedByRefLikeMethodMember(IMethodSymbol method, SourceProductionContext context) + { + var diagnostic = NativeAotSignatureDiagnosticRules.Analyze(method); + if (!diagnostic.HasDiagnostic) + { + return false; + } + + switch (diagnostic.Kind) + { + case NativeAotSignatureDiagnosticKind.ByRefLikeReturn: + context.ReportDiagnostic(GeneratorDiagnostics.UnsupportedByRefLikeReturn(method)); + break; + case NativeAotSignatureDiagnosticKind.ByRefLikeParamsParameter: + context.ReportDiagnostic(GeneratorDiagnostics.UnsupportedByRefLikeParams(method, diagnostic.Parameter!)); + break; + case NativeAotSignatureDiagnosticKind.ByRefLikeParameter: + context.ReportDiagnostic(GeneratorDiagnostics.UnsupportedByRefLikeParameter(method, diagnostic.Parameter!)); + break; + } + + return true; + } + private static IEnumerable GetProxyablePropertyAccessors(INamedTypeSymbol serviceType) { var properties = serviceType.TypeKind == TypeKind.Interface @@ -1622,11 +1638,6 @@ private static IEnumerable GetProxyableMethods(INamedTypeSymbol s .Where(IsOverridable); } - private static bool IsByRefLikeType(ITypeSymbol type) - { - return type is INamedTypeSymbol { IsRefLikeType: true }; - } - private static string EmitArgument(IParameterSymbol p) { var prefix = p.RefKind switch diff --git a/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs b/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..de241dac --- /dev/null +++ b/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AspectCore.Core.Tests, PublicKey=" + + "0024000004800000940000000602000000240000525341310004000001000100E5A34DFA0BD597" + + "39067521C28B809E6653358A008148F35C8D3357DC02D90EF3EB3365FB55903BDCD14DBFE2B73A" + + "10361C71C948B5FFCEC2BF17E6C7A2EF98494D34D6E00D671B32566D153B8139D1CAA0D5A9B071" + + "E15B6849FBABEA83FC9B8B6ABF959E606F5E51B268A6A6C2D4757BBC3AE33689373FAAEDF61077" + + "59678C9B")] diff --git a/tests/AspectCore.Core.Tests/EngineParity/SourceGeneratorDiagnosticTests.cs b/tests/AspectCore.Core.Tests/EngineParity/SourceGeneratorDiagnosticTests.cs index 0d0c1343..6fd7c9ce 100644 --- a/tests/AspectCore.Core.Tests/EngineParity/SourceGeneratorDiagnosticTests.cs +++ b/tests/AspectCore.Core.Tests/EngineParity/SourceGeneratorDiagnosticTests.cs @@ -42,6 +42,162 @@ public sealed record SealedRecordService(string Name) Assert.Contains("SealedRecordService", diagnostic.GetMessage()); } + [Fact] + public void ReadOnlySpanParameter_ReportsACSG010() + { + const string source = """ + using System; + using AspectCore.DynamicProxy; + + [AspectCoreGenerateProxy] + public class ReadOnlySpanParameterService + { + public virtual int Length(ReadOnlySpan values) => values.Length; + } + """; + + var compilation = CSharpCompilation.Create( + assemblyName: "ReadOnlySpanParameterDiagnostic", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp7_3)) }, + references: AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic + && !string.IsNullOrEmpty(assembly.Location) + && assembly != typeof(SourceGeneratorDiagnosticTests).Assembly) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new AspectCoreProxyGenerator().AsSourceGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var generatorDiagnostics); + + var diagnostic = Assert.Single(generatorDiagnostics.Where(d => d.Id == "ACSG010")); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Equal("values", diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan)); + Assert.Contains("ReadOnlySpanParameterService.Length", diagnostic.GetMessage()); + Assert.Contains("values", diagnostic.GetMessage()); + } + + [Fact] + public void ReadOnlySpanReturn_ReportsACSG011() + { + const string source = """ + using System; + using AspectCore.DynamicProxy; + + [AspectCoreGenerateProxy] + public class ReadOnlySpanReturnService + { + public virtual ReadOnlySpan GetValues(int[] values) => values; + } + """; + + var compilation = CSharpCompilation.Create( + assemblyName: "ReadOnlySpanReturnDiagnostic", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp7_3)) }, + references: AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic + && !string.IsNullOrEmpty(assembly.Location) + && assembly != typeof(SourceGeneratorDiagnosticTests).Assembly) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new AspectCoreProxyGenerator().AsSourceGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var generatorDiagnostics); + + var diagnostic = Assert.Single(generatorDiagnostics.Where(d => d.Id == "ACSG011")); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains("ReadOnlySpanReturnService.GetValues", diagnostic.GetMessage()); + Assert.Contains("System.ReadOnlySpan", diagnostic.GetMessage()); + } + + [Fact] + public void NativeAotSignatureDiagnosticRules_NoByRefLikeSignature_ReturnsNone() + { + const string source = """ + public class PlainService + { + public int Add(int left, int right) => left + right; + } + """; + + var method = GetSingleMethod(source, "PlainService", "Add", LanguageVersion.CSharp7_3); + + var diagnostic = NativeAotSignatureDiagnosticRules.Analyze(method); + + Assert.False(diagnostic.HasDiagnostic); + Assert.Equal(NativeAotSignatureDiagnosticKind.None, diagnostic.Kind); + Assert.Same(method, diagnostic.Method); + Assert.Null(diagnostic.Parameter); + } + + [Fact] + public void NativeAotSignatureDiagnosticRules_ByRefLikeParameter_ReturnsParameterDiagnostic() + { + const string source = """ + using System; + + public class SpanParameterService + { + public int Length(ReadOnlySpan values) => values.Length; + } + """; + + var method = GetSingleMethod(source, "SpanParameterService", "Length", LanguageVersion.CSharp7_3); + + var diagnostic = NativeAotSignatureDiagnosticRules.Analyze(method); + + Assert.True(diagnostic.HasDiagnostic); + Assert.Equal(NativeAotSignatureDiagnosticKind.ByRefLikeParameter, diagnostic.Kind); + Assert.Same(method, diagnostic.Method); + Assert.Equal("values", diagnostic.Parameter?.Name); + } + + [Fact] + public void NativeAotSignatureDiagnosticRules_ByRefLikeReturn_TakesPrecedenceOverParameter() + { + const string source = """ + using System; + + public class SpanReturnService + { + public ReadOnlySpan Slice(ReadOnlySpan values) => values; + } + """; + + var method = GetSingleMethod(source, "SpanReturnService", "Slice", LanguageVersion.CSharp7_3); + + var diagnostic = NativeAotSignatureDiagnosticRules.Analyze(method); + + Assert.True(diagnostic.HasDiagnostic); + Assert.Equal(NativeAotSignatureDiagnosticKind.ByRefLikeReturn, diagnostic.Kind); + Assert.Same(method, diagnostic.Method); + Assert.Null(diagnostic.Parameter); + } + +#if NET10_0_OR_GREATER + [Fact] + public void NativeAotSignatureDiagnosticRules_ByRefLikeParamsParameter_ReturnsParamsDiagnostic() + { + const string source = """ + using System; + + public class SpanParamsService + { + public int Length(params ReadOnlySpan values) => values.Length; + } + """; + + var method = GetSingleMethod(source, "SpanParamsService", "Length", LanguageVersion.CSharp13); + + var diagnostic = NativeAotSignatureDiagnosticRules.Analyze(method); + + Assert.True(diagnostic.HasDiagnostic); + Assert.Equal(NativeAotSignatureDiagnosticKind.ByRefLikeParamsParameter, diagnostic.Kind); + Assert.Same(method, diagnostic.Method); + Assert.Equal("values", diagnostic.Parameter?.Name); + Assert.True(diagnostic.Parameter?.IsParams); + } +#endif + #if NET10_0_OR_GREATER [Fact] public void ParamsReadOnlySpanIndexer_ReportsACSG009OnIndexerParameter() @@ -77,4 +233,25 @@ public class ParamsReadOnlySpanIndexerService Assert.Contains("values", diagnostic.GetMessage()); } #endif + + private static IMethodSymbol GetSingleMethod(string source, string typeName, string methodName, LanguageVersion languageVersion) + { + var compilation = CSharpCompilation.Create( + assemblyName: $"{typeName}DiagnosticRules", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(languageVersion)) }, + references: AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic + && !string.IsNullOrEmpty(assembly.Location) + && assembly != typeof(SourceGeneratorDiagnosticTests).Assembly) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var type = compilation.GlobalNamespace + .GetTypeMembers(typeName) + .Single(); + + return type.GetMembers(methodName) + .OfType() + .Single(m => m.MethodKind == MethodKind.Ordinary); + } } diff --git a/tests/AspectCore.E2E.Tests/Scenarios/NativeAotSourceGeneratedScenarios.cs b/tests/AspectCore.E2E.Tests/Scenarios/NativeAotSourceGeneratedScenarios.cs index 2491eedf..5ba1d82b 100644 --- a/tests/AspectCore.E2E.Tests/Scenarios/NativeAotSourceGeneratedScenarios.cs +++ b/tests/AspectCore.E2E.Tests/Scenarios/NativeAotSourceGeneratedScenarios.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using AspectCore.Configuration; @@ -771,6 +772,202 @@ public void AspectCoreGenericHintAttribute_Can_Be_Instantiated() Assert.Equal(typeof(string), attr.TypeArguments[1]); } + [Fact] + public async Task SourceGeneratedAspectContext_DirectConstructor_Complete_UsesInvokeDelegate() + { + var serviceMethod = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var implementationMethod = typeof(SgBasicService).GetMethod(nameof(SgBasicService.Add))!; + var proxyMethod = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var implementation = new SgBasicService(); + var proxy = new SgBasicService(); + var parameters = new object[] { 3, 4 }; + var context = CreateSourceGeneratedContext( + serviceProvider, + serviceMethod, + implementationMethod, + proxyMethod, + serviceMethod, + implementation, + proxy, + parameters, + new ConstantInvokeDelegate(7)); + + Assert.Same(serviceProvider, context.ServiceProvider); + Assert.Same(serviceMethod, context.ServiceMethod); + Assert.Same(implementationMethod, context.ImplementationMethod); + Assert.Same(proxyMethod, context.ProxyMethod); + Assert.Same(serviceMethod, context.PredicateMethod); + Assert.Same(implementation, context.Implementation); + Assert.Same(proxy, context.Proxy); + Assert.Same(parameters, context.Parameters); + + await context.Complete(); + + Assert.Equal(7, context.ReturnValue); + (context as IDisposable)?.Dispose(); + } + + [Fact] + public void SourceGeneratedAspectContext_ServiceProvider_Throws_WhenMissing() + { + var method = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var context = CreateSourceGeneratedContext( + serviceProvider: null, + serviceMethod: method, + implementationMethod: method, + proxyMethod: method, + predicateMethod: method, + targetInstance: new SgBasicService(), + proxyInstance: new SgBasicService(), + parameters: Array.Empty(), + invokeDelegate: new ConstantInvokeDelegate(0)); + + Assert.Throws(() => context.ServiceProvider); + (context as IDisposable)?.Dispose(); + } + + [Fact] + public void SourceGeneratedAspectContext_Dispose_CleansAdditionalData() + { + var method = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var disposed = false; + var context = CreateSourceGeneratedContext( + new ServiceCollection().BuildServiceProvider(), + method, + method, + method, + method, + new SgBasicService(), + new SgBasicService(), + Array.Empty(), + new ConstantInvokeDelegate(0)); + + context.AdditionalData["disposable"] = new DisposableTracker(() => disposed = true); + context.AdditionalData["plain"] = "value"; + + (context as IDisposable)?.Dispose(); + (context as IDisposable)?.Dispose(); + + Assert.True(disposed); + } + + [Fact] + public async Task SourceGeneratedAspectContext_Complete_WithNullImplementation_Breaks() + { + var method = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var context = CreateSourceGeneratedContext( + new ServiceCollection().BuildServiceProvider(), + method, + method, + method, + method, + targetInstance: null, + proxyInstance: new SgBasicService(), + parameters: Array.Empty(), + invokeDelegate: new ConstantInvokeDelegate(42)); + + await context.Complete(); + + Assert.Equal(0, context.ReturnValue); + (context as IDisposable)?.Dispose(); + } + + [Fact] + public async Task SourceGeneratedAspectContext_Break_ByRefReturn_UsesElementDefault() + { + var method = typeof(RefReturnService).GetMethod(nameof(RefReturnService.GetValue))!; + var service = new RefReturnService(); + var context = CreateSourceGeneratedContext( + new ServiceCollection().BuildServiceProvider(), + method, + method, + method, + method, + service, + service, + Array.Empty(), + new ConstantInvokeDelegate(42)); + + await context.Break(); + + Assert.Equal(0, context.ReturnValue); + (context as IDisposable)?.Dispose(); + } + + [Fact] + public async Task SourceGeneratedAspectContext_Invoke_CallsNext() + { + var method = typeof(ISgBasicService).GetMethod(nameof(ISgBasicService.Add))!; + var context = CreateSourceGeneratedContext( + new ServiceCollection().BuildServiceProvider(), + method, + method, + method, + method, + new SgBasicService(), + new SgBasicService(), + Array.Empty(), + new ConstantInvokeDelegate(0)); + var invoked = false; + + await context.Invoke(_ => + { + invoked = true; + return Task.CompletedTask; + }); + + Assert.True(invoked); + (context as IDisposable)?.Dispose(); + } + + private static AspectContext CreateSourceGeneratedContext( + IServiceProvider? serviceProvider, + MethodInfo serviceMethod, + MethodInfo implementationMethod, + MethodInfo proxyMethod, + MethodInfo predicateMethod, + object? targetInstance, + object? proxyInstance, + object[] parameters, + IAspectInvokeDelegate invokeDelegate) + { + var contextType = typeof(AspectContextFactory).Assembly.GetType( + "AspectCore.DynamicProxy.SourceGeneratedAspectContext", + throwOnError: true)!; + var constructor = contextType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public, + binder: null, + new[] + { + typeof(IServiceProvider), + typeof(MethodInfo), + typeof(MethodInfo), + typeof(MethodInfo), + typeof(MethodInfo), + typeof(object), + typeof(object), + typeof(object[]), + typeof(IAspectInvokeDelegate) + }, + modifiers: null); + + Assert.NotNull(constructor); + + return (AspectContext)constructor!.Invoke(new object?[] + { + serviceProvider, + serviceMethod, + implementationMethod, + proxyMethod, + predicateMethod, + targetInstance, + proxyInstance, + parameters, + invokeDelegate + }); + } + private sealed class MinimalFactory : IAspectContextFactory { public AspectContext CreateContext(AspectActivatorContext activatorContext) @@ -790,4 +987,23 @@ private sealed class DummyDelegate : IAspectInvokeDelegate { public object Invoke(object instance, object[] parameters) => null!; } + + private sealed class ConstantInvokeDelegate : IAspectInvokeDelegate + { + private readonly object? _value; + + public ConstantInvokeDelegate(object? value) + { + _value = value; + } + + public object Invoke(object instance, object[] parameters) => _value!; + } + + private sealed class RefReturnService + { + private int _value; + + public ref int GetValue() => ref _value; + } } From 7a114c00540a439af8b38edf5573106dc2903327 Mon Sep 17 00:00:00 2001 From: Haoyang Liu Date: Fri, 24 Jul 2026 22:14:19 +0800 Subject: [PATCH 2/2] fix: address nativeaot coverage review feedback --- .github/scripts/check-coverage.sh | 119 +++++++++++++++--- .github/workflows/build-pr-ci.yml | 4 +- .../Emit/NativeAotSignatureDiagnostic.cs | 2 + .../Properties/AssemblyInfo.cs | 4 + 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/.github/scripts/check-coverage.sh b/.github/scripts/check-coverage.sh index cc14a7b3..43d38b79 100755 --- a/.github/scripts/check-coverage.sh +++ b/.github/scripts/check-coverage.sh @@ -127,20 +127,109 @@ scope_files = set(sys.argv[2:]) root = ET.parse(coverage_file).getroot() covered = 0 valid = 0 +matched = set() + + +def matches_scope(filename, scope): + return filename == scope or filename.endswith("/" + scope) or filename.endswith("\\" + scope) + for cls in root.findall(".//class"): filename = cls.attrib.get("filename", "") - if scope_files and filename not in scope_files: - continue + if scope_files: + matched_scope = next((scope for scope in scope_files if matches_scope(filename, scope)), None) + if matched_scope is None: + continue + matched.add(filename) + else: + matched.add(filename) for line in cls.findall("./lines/line"): valid += 1 if int(line.attrib.get("hits", "0")) > 0: covered += 1 +print(f" Scope matched classes: {len(matched)}", file=sys.stderr) +for filename in sorted(matched): + print(f" {filename}", file=sys.stderr) + if valid == 0: + print("0.00") +else: + print(f"{covered * 100 / valid:.2f}") +PY +} + +coverage_passed() { + local coverage="$1" + local threshold="$2" + + python3 - "$coverage" "$threshold" <<'PY' +import sys + +coverage = float(sys.argv[1]) +threshold = float(sys.argv[2]) +sys.exit(0 if coverage + 1e-9 >= threshold else 1) +PY +} + +coverage_is_zero() { + local coverage="$1" + + python3 - "$coverage" <<'PY' +import sys + +coverage = float(sys.argv[1]) +sys.exit(0 if coverage == 0 else 1) +PY +} + +coverage_is_number() { + local coverage="$1" + + python3 - "$coverage" <<'PY' +import sys + +try: + float(sys.argv[1]) +except ValueError: + sys.exit(1) +sys.exit(0) +PY +} + +integer_average() { + python3 - "$@" <<'PY' +import sys + +values = [float(v) for v in sys.argv[1:]] +if not values: print("0") else: - print((covered * 100) // valid) + print(str(int(sum(values) // len(values)))) +PY +} + +decimal_average() { + python3 - "$@" <<'PY' +import sys + +values = [float(v) for v in sys.argv[1:]] +if not values: + print("0.00") +else: + print(f"{sum(values) / len(values):.2f}") +PY +} + +root_line_rate() { + local coverage_file="$1" + + python3 - "$coverage_file" <<'PY' +import sys +import xml.etree.ElementTree as ET + +root = ET.parse(sys.argv[1]).getroot() +print(float(root.attrib.get("line-rate", "0")) * 100) PY } @@ -215,10 +304,8 @@ run_coverage() { mapfile -t scope_files < <(nativeaot_scope_files "$mode") coverage_pct=$(coverage_from_cobertura "$coverage_file" "${scope_files[@]}") else - line_rate=$(grep -o 'line-rate="[^"]*"' "$coverage_file" | head -1 | cut -d'"' -f2) - if [[ -n "$line_rate" ]]; then - coverage_pct=$(echo "$line_rate * 100" | bc | cut -d. -f1) - fi + coverage_pct=$(root_line_rate "$coverage_file") + coverage_pct=$(printf '%.0f\n' "$coverage_pct") fi if [[ -n "$coverage_pct" ]]; then echo " Coverage: ${coverage_pct}%" >&2 @@ -241,10 +328,10 @@ collect_coverage() { local exclude_pattern local coverage local coverage_count=0 - local coverage_sum=0 local coverage_average=0 local find_arguments local test_project + local coverages=() metadata=$(mode_metadata "$mode") IFS='|' read -r heading threshold project_pattern exclude_pattern <<< "$metadata" @@ -260,16 +347,20 @@ collect_coverage() { return 1 fi - if [[ "$coverage" == "0" || -z "$coverage" ]]; then + if [[ -z "$coverage" ]] || coverage_is_zero "$coverage"; then continue fi coverage_count=$((coverage_count + 1)) - coverage_sum=$((coverage_sum + coverage)) + coverages+=("$coverage") done < <(find "${find_arguments[@]}" | sort) if [[ "$coverage_count" -gt 0 ]]; then - coverage_average=$((coverage_sum / coverage_count)) + if nativeaot_mode "$mode"; then + coverage_average=$(decimal_average "${coverages[@]}") + else + coverage_average=$(integer_average "${coverages[@]}") + fi fi mkdir -p "$(dirname "$output_file")" @@ -331,8 +422,8 @@ assert_coverage() { projects=$(result_value projects "$input_file") fi - if [[ "$result_mode" == "$mode" && "$threshold" == "$expected_threshold" && "$coverage" =~ ^[0-9]+$ && "$projects" =~ ^[1-9][0-9]*$ ]]; then - if (( coverage >= threshold )); then + if [[ "$result_mode" == "$mode" && "$threshold" == "$expected_threshold" && "$projects" =~ ^[1-9][0-9]*$ ]]; then + if coverage_is_number "$coverage" && coverage_passed "$coverage" "$threshold"; then passed=true fi fi @@ -351,7 +442,7 @@ assert_coverage() { return 0 fi - if [[ "$coverage" =~ ^[0-9]+$ ]]; then + if coverage_is_number "$coverage"; then echo "FAIL: ${heading} coverage ${coverage}% is below threshold ${expected_threshold}%" else echo "FAIL: ${heading} coverage result is missing or invalid." diff --git a/.github/workflows/build-pr-ci.yml b/.github/workflows/build-pr-ci.yml index c9944016..98760e3e 100644 --- a/.github/workflows/build-pr-ci.yml +++ b/.github/workflows/build-pr-ci.yml @@ -396,11 +396,11 @@ jobs: const unitThreshold = process.env.UNIT_THRESHOLD; const e2eCoverage = process.env.E2E_COVERAGE; const e2eThreshold = process.env.E2E_THRESHOLD; - const title = `NativeAOT Test: passed (UT ${unitCoverage}% / min ${unitThreshold}%, E2E ${e2eCoverage}% / min ${e2eThreshold}%)`; + const title = `NativeAOT Test Execution: collected (UT ${unitCoverage}%, E2E ${e2eCoverage}%)`; const summary = [ "## NativeAOT Test Execution", "", - "NativeAOT focused unit and E2E coverage were collected successfully.", + "NativeAOT focused unit and E2E tests completed and coverage was collected. Threshold enforcement is reported by the NativeAOT Coverage check.", "", "| Scope | Current PR | Threshold |", "| --- | --- | --- |", diff --git a/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs b/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs index ee36a5e5..da2c453b 100644 --- a/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs +++ b/src/AspectCore.SourceGenerator/Emit/NativeAotSignatureDiagnostic.cs @@ -41,6 +41,8 @@ public static NativeAotSignatureDiagnostic Analyze(IMethodSymbol method) parameter: null); } + // Report the first unsupported byref-like parameter only. This keeps diagnostics + // focused and matches the existing Source Generator behavior for unsupported params. var parameter = method.Parameters.FirstOrDefault(p => IsByRefLikeType(p.Type)); if (parameter is null) { diff --git a/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs b/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs index de241dac..aa5a3e07 100644 --- a/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs +++ b/src/AspectCore.SourceGenerator/Properties/AssemblyInfo.cs @@ -1,8 +1,12 @@ using System.Runtime.CompilerServices; +#if DEBUG +[assembly: InternalsVisibleTo("AspectCore.Core.Tests")] +#else [assembly: InternalsVisibleTo("AspectCore.Core.Tests, PublicKey=" + "0024000004800000940000000602000000240000525341310004000001000100E5A34DFA0BD597" + "39067521C28B809E6653358A008148F35C8D3357DC02D90EF3EB3365FB55903BDCD14DBFE2B73A" + "10361C71C948B5FFCEC2BF17E6C7A2EF98494D34D6E00D671B32566D153B8139D1CAA0D5A9B071" + "E15B6849FBABEA83FC9B8B6ABF959E606F5E51B268A6A6C2D4757BBC3AE33689373FAAEDF61077" + "59678C9B")] +#endif