fix(game): 剥离运行时模块对生成器依赖

- 修复 GFramework.Game 对 SourceGenerators.Abstractions 的项目引用并移除未使用的枚举生成 attribute

- 新增 runtime-generator 边界校验脚本并接入 CI 与发布打包校验

- 更新 AGENTS、贡献文档与 ai-plan 跟踪,明确运行时模块禁止依赖生成器能力
This commit is contained in:
gewuyou 2026-05-05 12:39:10 +08:00
parent c69942d66e
commit 7288114e33
11 changed files with 371 additions and 7 deletions

View File

@ -35,6 +35,9 @@ jobs:
- name: Validate license headers - name: Validate license headers
run: python3 scripts/license-header.py --check run: python3 scripts/license-header.py --check
- name: Validate runtime-generator boundaries
run: python3 scripts/validate-runtime-generator-boundaries.py
# 缓存MegaLinter # 缓存MegaLinter
- name: Cache MegaLinter - name: Cache MegaLinter
uses: actions/cache@v5 uses: actions/cache@v5

View File

@ -118,6 +118,9 @@ jobs:
diff -u expected-packages.txt actual-packages.txt diff -u expected-packages.txt actual-packages.txt
- name: Validate runtime-generator package boundaries
run: python3 scripts/validate-runtime-generator-boundaries.py --package-dir ./packages
- name: Show packages - name: Show packages
run: ls -la ./packages || true run: ls -la ./packages || true

View File

@ -212,6 +212,9 @@ All generated or modified code MUST include clear and meaningful comments where
- Private fields: `_camelCase` - Private fields: `_camelCase`
- Keep abstractions projects free of implementation details and engine-specific dependencies. - Keep abstractions projects free of implementation details and engine-specific dependencies.
- Preserve existing module boundaries. Do not introduce new cross-module dependencies without clear architectural need. - Preserve existing module boundaries. Do not introduce new cross-module dependencies without clear architectural need.
- Framework runtime, abstractions, and meta-package projects MUST NOT reference `*.SourceGenerators*` projects or packages,
and MUST NOT use source-generator attributes such as `GenerateEnumExtensions` or `ContextAware`. Those capabilities are
reserved for consumer projects, generator projects, examples explicitly meant to demonstrate generator usage, and related tests.
### Formatting ### Formatting

View File

@ -1,14 +1,11 @@
// Copyright (c) 2025-2026 GeWuYou // Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
using GFramework.Core.SourceGenerators.Abstractions.Enums;
namespace GFramework.Game.Config; namespace GFramework.Game.Config;
/// <summary> /// <summary>
/// 表示当前运行时 schema 校验器支持的属性类型。 /// 表示当前运行时 schema 校验器支持的属性类型。
/// </summary> /// </summary>
[GenerateEnumExtensions]
internal enum YamlConfigSchemaPropertyType internal enum YamlConfigSchemaPropertyType
{ {
/// <summary> /// <summary>

View File

@ -1,14 +1,11 @@
// Copyright (c) 2025-2026 GeWuYou // Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
using GFramework.Core.SourceGenerators.Abstractions.Enums;
namespace GFramework.Game.Config; namespace GFramework.Game.Config;
/// <summary> /// <summary>
/// 表示当前 Runtime / Generator / Tooling 共享支持的字符串 format 子集。 /// 表示当前 Runtime / Generator / Tooling 共享支持的字符串 format 子集。
/// </summary> /// </summary>
[GenerateEnumExtensions]
internal enum YamlConfigStringFormatKind internal enum YamlConfigStringFormatKind
{ {
/// <summary> /// <summary>

View File

@ -14,7 +14,6 @@
<EnableGFrameworkPackageTransitiveGlobalUsings>true</EnableGFrameworkPackageTransitiveGlobalUsings> <EnableGFrameworkPackageTransitiveGlobalUsings>true</EnableGFrameworkPackageTransitiveGlobalUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GFramework.Core.SourceGenerators.Abstractions\GFramework.Core.SourceGenerators.Abstractions.csproj" />
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/> <ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj"/> <ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj"/>
</ItemGroup> </ItemGroup>

View File

@ -42,6 +42,10 @@ help the current worktree land on the right recovery documents without scanning
- Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow. - Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow.
- Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md` - Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md`
- Trace: `ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md` - Trace: `ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md`
- `runtime-generator-boundary`
- Purpose: keep runtime and abstractions packages isolated from source-generator dependencies, packaging leaks, and attribute usage.
- Tracking: `ai-plan/public/runtime-generator-boundary/todos/runtime-generator-boundary-tracking.md`
- Trace: `ai-plan/public/runtime-generator-boundary/traces/runtime-generator-boundary-trace.md`
## Worktree To Active Topic Map ## Worktree To Active Topic Map
@ -68,6 +72,9 @@ help the current worktree land on the right recovery documents without scanning
- Branch: `fix/release-notes-pr-links` - Branch: `fix/release-notes-pr-links`
- Worktree hint: `GFramework` - Worktree hint: `GFramework`
- Priority 1: `semantic-release-versioning` - Priority 1: `semantic-release-versioning`
- Branch: `fix/runtime-generator-boundary`
- Worktree hint: `GFramework`
- Priority 1: `runtime-generator-boundary`
- Branch: `docs/sdk-update-documentation` - Branch: `docs/sdk-update-documentation`
- Worktree hint: `GFramework-update-documentation` - Worktree hint: `GFramework-update-documentation`
- Priority 1: `documentation-full-coverage-governance` - Priority 1: `documentation-full-coverage-governance`

View File

@ -0,0 +1,40 @@
# Runtime / Generator Boundary Tracking
## Goal
Keep runtime, abstractions, and meta-package modules free from source-generator project references, source-generator
attributes, and leaked NuGet dependencies.
## Current Recovery Point
- Recovery point: RGB-RP-001
- Phase: remove `GFramework.Game` generator coupling and add repository guardrails
- Focus:
- delete `GFramework.Game`'s dependency on `GFramework.Core.SourceGenerators.Abstractions`
- remove unused `[GenerateEnumExtensions]` usage from `GFramework.Game`
- add static and packed-package validation so runtime packages cannot regress
## Active Risks
- A runtime package can still compile locally if it references a non-packable generator helper project, so regressions are
easy to miss without an explicit guard.
- A leaked package dependency may only surface when a consumer restores from NuGet, not during normal repository builds.
## Completed In This Stage
- Confirmed `GFramework.Game` was the direct runtime offender and `GeWuYou.GFramework.Game` leaked
`GFramework.Core.SourceGenerators.Abstractions` into its nuspec dependency graph.
- Confirmed the two `[GenerateEnumExtensions]` usages inside `GFramework.Game` do not need generated output and can be
removed outright.
## Validation Target
- `python3 scripts/validate-runtime-generator-boundaries.py`
- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release`
- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release`
- `dotnet pack GFramework.sln -c Release -p:PackageVersion=<local>`
## Next Recommended Resume Step
Run the new boundary validator plus minimal Release build and pack validation, then inspect `GeWuYou.GFramework.Game`
and transitive runtime packages to confirm no `SourceGenerators` dependency remains.

View File

@ -0,0 +1,21 @@
# Runtime / Generator Boundary Trace
## 2026-05-05
### RGB-RP-001 Runtime package boundary repair
- Trigger:
- external consumers restoring `GeWuYou.GFramework.Game` failed because NuGet looked for
`GFramework.Core.SourceGenerators.Abstractions`
- repository inspection showed `GFramework.Game` had a direct project reference to a non-packable generator
abstractions project and used `[GenerateEnumExtensions]`
- Decisions:
- treat the issue as a runtime/generator boundary violation, not as a missing publish target
- remove the runtime-side attribute usage instead of turning generator abstractions into public runtime packages
- add repository guardrails at both source-validation time and packed-package validation time
- Expected implementation:
- `GFramework.Game` removes the generator abstractions project reference
- `GFramework.Game` removes the two unused enum generator attributes
- CI and publish workflows run a dedicated boundary validator script
- Immediate next step:
- complete implementation and run Release build plus pack verification

View File

@ -218,6 +218,14 @@ git push origin your-branch
### 命名规范 ### 命名规范
### 代码生成器边界
- 框架内部的运行时模块、抽象层模块和顶层元包不得依赖 `*.SourceGenerators*` 项目或包。
- `GenerateEnumExtensions``ContextAware``GetModel``GetService` 等代码生成器 attribute 只允许出现在消费端项目、
生成器项目本身、专门验证生成器行为的测试项目,或明确用于演示生成器接入的示例中。
- 如果某个运行时模块为了编译而需要引入生成器 attribute说明模块边界已经漂移应优先移除该 attribute 使用,而不是把
generator abstractions 暴露成新的运行时依赖。
遵循 C# 标准命名约定: 遵循 C# 标准命名约定:
- **类、接口、方法**PascalCase - **类、接口、方法**PascalCase

View File

@ -0,0 +1,286 @@
#!/usr/bin/env python3
# Copyright (c) 2025-2026 GeWuYou
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import argparse
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNTIME_PROJECTS = (
"GFramework",
"GFramework.Core",
"GFramework.Core.Abstractions",
"GFramework.Cqrs",
"GFramework.Cqrs.Abstractions",
"GFramework.Game",
"GFramework.Game.Abstractions",
"GFramework.Godot",
"GFramework.Ecs.Arch",
"GFramework.Ecs.Arch.Abstractions",
)
FORBIDDEN_ATTRIBUTE_NAMES = (
"GenerateEnumExtensions",
"ContextAware",
"GetModel",
"GetModels",
"GetSystem",
"GetSystems",
"GetUtility",
"GetUtilities",
"GetService",
"GetServices",
"GetAll",
"Log",
"Priority",
)
FORBIDDEN_PROJECT_REFERENCE_PREFIX = "GFramework."
FORBIDDEN_PACKAGE_REFERENCE_PREFIX = "GeWuYou.GFramework."
PACKAGE_NAMESPACE = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"
PACKAGE_NAMESPACE_2012 = "http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd"
@dataclass(frozen=True)
class Violation:
location: str
message: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate that runtime and abstractions modules do not depend on source-generator packages or attributes."
)
parser.add_argument(
"--package-dir",
type=Path,
help="Optional package directory. When supplied, validate packed runtime nuspec dependencies as well.",
)
return parser.parse_args()
def get_project_file(project_name: str) -> Path:
if project_name == "GFramework":
return REPO_ROOT / "GFramework.csproj"
return REPO_ROOT / project_name / f"{project_name}.csproj"
def get_source_root(project_name: str) -> Path | None:
if project_name == "GFramework":
return None
return REPO_ROOT / project_name
def get_local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def get_xml_text(element: ET.Element) -> str:
return (element.text or "").strip()
def get_runtime_package_ids() -> set[str]:
package_ids: set[str] = set()
for project_name in RUNTIME_PROJECTS:
project_file = get_project_file(project_name)
tree = ET.parse(project_file)
root = tree.getroot()
package_id = None
assembly_name = None
for element in root.iter():
local_name = get_local_name(element.tag)
if local_name == "PackageId" and not package_id:
package_id = get_xml_text(element)
elif local_name == "AssemblyName" and not assembly_name:
assembly_name = get_xml_text(element)
resolved_package_id = package_id or f"GeWuYou.{assembly_name or project_name}"
package_ids.add(resolved_package_id)
return package_ids
def validate_project_references() -> list[Violation]:
violations: list[Violation] = []
for project_name in RUNTIME_PROJECTS:
project_file = get_project_file(project_name)
tree = ET.parse(project_file)
for element in tree.getroot().iter():
local_name = get_local_name(element.tag)
if local_name not in {"ProjectReference", "PackageReference"}:
continue
include = element.attrib.get("Include", "").strip()
if local_name == "ProjectReference":
if FORBIDDEN_PROJECT_REFERENCE_PREFIX not in include or "SourceGenerators" not in include:
continue
else:
if not include.startswith(FORBIDDEN_PACKAGE_REFERENCE_PREFIX) or "SourceGenerators" not in include:
continue
violations.append(
Violation(
location=str(project_file.relative_to(REPO_ROOT)),
message=f"forbidden {local_name} -> {include}",
)
)
return violations
def compile_attribute_patterns() -> dict[str, re.Pattern[str]]:
patterns: dict[str, re.Pattern[str]] = {}
for attribute_name in FORBIDDEN_ATTRIBUTE_NAMES:
patterns[attribute_name] = re.compile(
rf"\[(?P<body>[^\]]*?(?:^|[\s,(])(?:global::)?(?:[A-Za-z_][A-Za-z0-9_]*\.)*{attribute_name}(?:Attribute)?(?=[\s,)\]])[^\]]*)\]",
re.MULTILINE,
)
return patterns
def line_number_for_offset(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
def validate_source_attributes() -> list[Violation]:
violations: list[Violation] = []
patterns = compile_attribute_patterns()
for project_name in RUNTIME_PROJECTS:
source_root = get_source_root(project_name)
if source_root is None or not source_root.is_dir():
continue
for file_path in source_root.rglob("*.cs"):
if any(part in {"bin", "obj"} for part in file_path.parts):
continue
text = file_path.read_text(encoding="utf-8-sig")
for attribute_name, pattern in patterns.items():
for match in pattern.finditer(text):
line_number = line_number_for_offset(text, match.start())
relative_path = file_path.relative_to(REPO_ROOT)
violations.append(
Violation(
location=f"{relative_path}:{line_number}",
message=f"forbidden source-generator attribute [{attribute_name}] in runtime module",
)
)
return violations
def iter_dependency_ids(nuspec_root: ET.Element) -> list[str]:
dependency_ids: list[str] = []
for namespace in (PACKAGE_NAMESPACE, PACKAGE_NAMESPACE_2012):
dependency_ids.extend(
element.attrib["id"]
for element in nuspec_root.findall(f".//{{{namespace}}}dependency")
if "id" in element.attrib
)
if dependency_ids:
return dependency_ids
dependency_ids.extend(
element.attrib["id"]
for element in nuspec_root.findall(".//dependency")
if "id" in element.attrib
)
return dependency_ids
def validate_packed_dependencies(package_dir: Path) -> list[Violation]:
violations: list[Violation] = []
runtime_package_ids = get_runtime_package_ids()
for package_path in sorted(package_dir.glob("*.nupkg")):
with zipfile.ZipFile(package_path) as archive:
nuspec_entries = [name for name in archive.namelist() if name.endswith(".nuspec")]
if not nuspec_entries:
violations.append(
Violation(
location=str(package_path.relative_to(REPO_ROOT if package_path.is_relative_to(REPO_ROOT) else package_dir.parent)),
message="missing nuspec entry",
)
)
continue
nuspec_root = ET.fromstring(archive.read(nuspec_entries[0]))
package_id_element = nuspec_root.find(f".//{{{PACKAGE_NAMESPACE}}}id")
if package_id_element is None:
package_id_element = nuspec_root.find(f".//{{{PACKAGE_NAMESPACE_2012}}}id")
if package_id_element is None:
package_id_element = nuspec_root.find(".//id")
package_id = get_xml_text(package_id_element) if package_id_element is not None else package_path.stem
if package_id not in runtime_package_ids:
continue
dependency_ids = iter_dependency_ids(nuspec_root)
for dependency_id in dependency_ids:
if not dependency_id.startswith(FORBIDDEN_PACKAGE_REFERENCE_PREFIX) and not dependency_id.startswith(
FORBIDDEN_PROJECT_REFERENCE_PREFIX
):
continue
if "SourceGenerators" not in dependency_id:
continue
violations.append(
Violation(
location=str(package_path),
message=f"runtime package {package_id} depends on forbidden package {dependency_id}",
)
)
return violations
def print_violations(violations: list[Violation]) -> None:
for violation in violations:
print(f"- {violation.location}: {violation.message}")
def main() -> int:
args = parse_args()
violations: list[Violation] = []
violations.extend(validate_project_references())
violations.extend(validate_source_attributes())
if args.package_dir is not None:
package_dir = args.package_dir if args.package_dir.is_absolute() else REPO_ROOT / args.package_dir
if not package_dir.is_dir():
print(f"Package directory does not exist: {package_dir}", file=sys.stderr)
return 2
violations.extend(validate_packed_dependencies(package_dir))
if violations:
print("Runtime/source-generator boundary validation failed.")
print_violations(violations)
return 1
print("Runtime/source-generator boundary validation passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())