diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7484aa96..644e5cd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Validate license headers run: python3 scripts/license-header.py --check + - name: Validate runtime-generator boundaries + run: python3 scripts/validate-runtime-generator-boundaries.py + # 缓存MegaLinter - name: Cache MegaLinter uses: actions/cache@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c9ba8930..500ac980 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -118,6 +118,9 @@ jobs: 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 run: ls -la ./packages || true diff --git a/AGENTS.md b/AGENTS.md index 55f4cc59..80e007a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,9 @@ All generated or modified code MUST include clear and meaningful comments where - Private fields: `_camelCase` - 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. +- 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 diff --git a/GFramework.Game/Config/YamlConfigSchemaPropertyType.cs b/GFramework.Game/Config/YamlConfigSchemaPropertyType.cs index af27219e..04bb0a43 100644 --- a/GFramework.Game/Config/YamlConfigSchemaPropertyType.cs +++ b/GFramework.Game/Config/YamlConfigSchemaPropertyType.cs @@ -1,14 +1,11 @@ // Copyright (c) 2025-2026 GeWuYou // SPDX-License-Identifier: Apache-2.0 -using GFramework.Core.SourceGenerators.Abstractions.Enums; - namespace GFramework.Game.Config; /// /// 表示当前运行时 schema 校验器支持的属性类型。 /// -[GenerateEnumExtensions] internal enum YamlConfigSchemaPropertyType { /// diff --git a/GFramework.Game/Config/YamlConfigStringFormatKind.cs b/GFramework.Game/Config/YamlConfigStringFormatKind.cs index 7233e88a..3b211933 100644 --- a/GFramework.Game/Config/YamlConfigStringFormatKind.cs +++ b/GFramework.Game/Config/YamlConfigStringFormatKind.cs @@ -1,14 +1,11 @@ // Copyright (c) 2025-2026 GeWuYou // SPDX-License-Identifier: Apache-2.0 -using GFramework.Core.SourceGenerators.Abstractions.Enums; - namespace GFramework.Game.Config; /// /// 表示当前 Runtime / Generator / Tooling 共享支持的字符串 format 子集。 /// -[GenerateEnumExtensions] internal enum YamlConfigStringFormatKind { /// diff --git a/GFramework.Game/GFramework.Game.csproj b/GFramework.Game/GFramework.Game.csproj index 6d2e3a10..add4ad19 100644 --- a/GFramework.Game/GFramework.Game.csproj +++ b/GFramework.Game/GFramework.Game.csproj @@ -14,7 +14,6 @@ true - diff --git a/ai-plan/public/README.md b/ai-plan/public/README.md index 139061a5..b4fb1655 100644 --- a/ai-plan/public/README.md +++ b/ai-plan/public/README.md @@ -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. - 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` +- `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 @@ -68,6 +72,9 @@ help the current worktree land on the right recovery documents without scanning - Branch: `fix/release-notes-pr-links` - Worktree hint: `GFramework` - Priority 1: `semantic-release-versioning` +- Branch: `fix/runtime-generator-boundary` + - Worktree hint: `GFramework` + - Priority 1: `runtime-generator-boundary` - Branch: `docs/sdk-update-documentation` - Worktree hint: `GFramework-update-documentation` - Priority 1: `documentation-full-coverage-governance` diff --git a/ai-plan/public/runtime-generator-boundary/todos/runtime-generator-boundary-tracking.md b/ai-plan/public/runtime-generator-boundary/todos/runtime-generator-boundary-tracking.md new file mode 100644 index 00000000..a0f8fb72 --- /dev/null +++ b/ai-plan/public/runtime-generator-boundary/todos/runtime-generator-boundary-tracking.md @@ -0,0 +1,56 @@ +# 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. +- Verified current PR review findings locally: the validator regex still missed standalone attributes, while the + `docs/zh-CN/contributing.md` generator-boundary text should be removed instead of repositioned because it is + maintainer-facing governance rather than reader-facing contribution guidance. +- Added a Python regression test for standalone, parameterized, fully qualified, and multi-attribute declarations so + future validator edits cannot silently reintroduce the false negative. +- Added comment-line filtering for the validator after the first regex fix started matching XML documentation examples + such as `/// [ContextAware]`, which would otherwise create false CI failures for reader-facing code comments. + +## Validation Target + +- `python3 scripts/validate-runtime-generator-boundaries.py` +- `python3 scripts/test_validate_runtime_generator_boundaries.py` +- `python3 scripts/license-header.py --check` +- `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=` + +## Latest Validation Result + +- `python3 scripts/test_validate_runtime_generator_boundaries.py` passed on 2026-05-05. +- `python3 scripts/validate-runtime-generator-boundaries.py` passed on 2026-05-05. +- `python3 scripts/license-header.py --check` passed on 2026-05-05. +- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release` passed on 2026-05-05 with 0 warnings and 0 errors. + +## Next Recommended Resume Step + +Run the boundary validator, the new Python regression tests, and the minimal Release build/pack validation; then push +the follow-up commit so the open PR review threads can be resolved against fresh CI. diff --git a/ai-plan/public/runtime-generator-boundary/traces/runtime-generator-boundary-trace.md b/ai-plan/public/runtime-generator-boundary/traces/runtime-generator-boundary-trace.md new file mode 100644 index 00000000..ef1fa31f --- /dev/null +++ b/ai-plan/public/runtime-generator-boundary/traces/runtime-generator-boundary-trace.md @@ -0,0 +1,36 @@ +# 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 +- PR review follow-up: + - verified CodeRabbit and Greptile findings against local source before acting on them + - accepted the validator regex finding because the original pattern missed standalone + `[GenerateEnumExtensions]` declarations in runtime code + - added comment-line filtering after the first regex repair surfaced false positives from XML documentation examples + such as `/// [ContextAware]` + - rejected the documentation reposition suggestion as stated and removed the + `代码生成器边界` block from `docs/zh-CN/contributing.md` because it documents internal governance rather than + reader-facing contributor guidance + - added a Python regression test covering standalone, parameterized, fully qualified, and multi-attribute matches +- Validation milestone: + - `python3 scripts/test_validate_runtime_generator_boundaries.py` passed + - `python3 scripts/validate-runtime-generator-boundaries.py` passed + - `python3 scripts/license-header.py --check` passed + - `dotnet build GFramework.Game/GFramework.Game.csproj -c Release` passed with 0 warnings and 0 errors +- Immediate next step: + - push the PR follow-up commit and resolve the remaining review threads diff --git a/scripts/test_validate_runtime_generator_boundaries.py b/scripts/test_validate_runtime_generator_boundaries.py new file mode 100644 index 00000000..41899213 --- /dev/null +++ b/scripts/test_validate_runtime_generator_boundaries.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-2026 GeWuYou +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for runtime/source-generator boundary validation.""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).resolve().parent / "validate-runtime-generator-boundaries.py" +MODULE_SPEC = importlib.util.spec_from_file_location("validate_runtime_generator_boundaries", MODULE_PATH) +if MODULE_SPEC is None or MODULE_SPEC.loader is None: + raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}") + +validate_runtime_generator_boundaries = importlib.util.module_from_spec(MODULE_SPEC) +sys.modules[MODULE_SPEC.name] = validate_runtime_generator_boundaries +MODULE_SPEC.loader.exec_module(validate_runtime_generator_boundaries) + + +class ValidateRuntimeGeneratorBoundariesTests(unittest.TestCase): + """Covers attribute matching edge cases that previously caused false negatives.""" + + def setUp(self) -> None: + self.patterns = validate_runtime_generator_boundaries.compile_attribute_patterns() + + def test_matches_standalone_attribute(self) -> None: + pattern = self.patterns["GenerateEnumExtensions"] + + self.assertIsNotNone(pattern.search("[GenerateEnumExtensions]")) + + def test_matches_parameterized_attribute(self) -> None: + pattern = self.patterns["GenerateEnumExtensions"] + + self.assertIsNotNone(pattern.search("[GenerateEnumExtensions(typeof(string))]")) + + def test_matches_non_leading_attribute_in_attribute_list(self) -> None: + pattern = self.patterns["GenerateEnumExtensions"] + + self.assertIsNotNone(pattern.search("[Serializable, GenerateEnumExtensions]")) + + def test_matches_fully_qualified_attribute(self) -> None: + pattern = self.patterns["Priority"] + + self.assertIsNotNone( + pattern.search("[global::GFramework.Core.SourceGenerators.Abstractions.Bases.PriorityAttribute(10)]") + ) + + def test_ignores_xml_doc_example_attribute(self) -> None: + text = "/// [ContextAware]\npublic interface IController;\n" + pattern = self.patterns["ContextAware"] + match = pattern.search(text) + + self.assertIsNotNone(match) + self.assertTrue(validate_runtime_generator_boundaries.is_comment_attribute_match(text, match.start())) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-runtime-generator-boundaries.py b/scripts/validate-runtime-generator-boundaries.py new file mode 100644 index 00000000..f27e237e --- /dev/null +++ b/scripts/validate-runtime-generator-boundaries.py @@ -0,0 +1,296 @@ +#!/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", # GFramework.Core.SourceGenerators.Abstractions.Logging.LogAttribute + "Priority", # GFramework.Core.SourceGenerators.Abstractions.Bases.PriorityAttribute +) + +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: + escaped_attribute_name = re.escape(attribute_name) + patterns[attribute_name] = re.compile( + rf"\[[^\]]*(?:(?<=\[)|(?<=[\s,(]))(?:global::)?(?:[A-Za-z_][A-Za-z0-9_]*\.)*{escaped_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 is_comment_attribute_match(text: str, offset: int) -> bool: + line_start = text.rfind("\n", 0, offset) + 1 + line_prefix = text[line_start:offset].lstrip() + return line_prefix.startswith("///") or line_prefix.startswith("//") or line_prefix.startswith("/*") or line_prefix.startswith("*") + + +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): + if is_comment_attribute_match(text, match.start()): + continue + + 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())