【整合】 框架使用规则

This commit is contained in:
Shiliang
2026-07-10 17:50:20 +08:00
parent db0143be83
commit 001d9e5ef5
45 changed files with 1716 additions and 4062 deletions

View File

@@ -1,89 +1,59 @@
# Hook 共享层
# 共享检查
> 本目录存放 UnityAI / StrayFog 框架知识库共享的 hook 脚本和规则
>
> 各 AI 工具自己的私有目录只放适配器,不重复维护 hook 逻辑。
`hooks/` 提供只读工作流检查和提交信息检查。脚本不自动修改代码、资源或文档
---
## 脚本清单
| 脚本 | 用途 |
|---|---|
| [archive-check.ps1](archive-check.ps1) | 读取 git diff 中变更的 `.md` 文件,解析 `## 文档关联` 表,输出需要同步检查的关联文档 |
| [sync-doc-associations.ps1](sync-doc-associations.ps1) | Python 脚本的 PowerShell 包装器 |
| [sync_doc_associations.py](sync_doc_associations.py) | 在关联文档中追加变更提醒(只追加、不覆盖,执行后需 review |
| [commit-check.ps1](commit-check.ps1) | 提交前检查:推断变更文件所属模块,校验提交信息格式 |
| [commit-module-mappings.json](commit-module-mappings.json) | `commit-check.ps1` 使用的目录-模块映射配置 |
---
## 归档检查
所有任务结束后都应执行归档检查,判断是否需要同步更新长期文档。
## 工作流检查
```powershell
# PowerShell
.\docs\hooks\archive-check.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File hooks/workflow-check.ps1 `
[-UnityProjectRoot <path>] [-DocsRoot <path>]
```
该脚本只输出建议,不会自动修改任何文档。最终是否更新关联文档,需要用户确认。
项目定位顺序:
---
1. `-UnityProjectRoot`
2. `UNITY_PROJECT_ROOT`
3. 当前目录本身是 Unity 项目
4. UnityAI 同级唯一 Unity 项目
5. 未找到时只检查文档仓库
## 文档关联同步
检查内容:
如需在关联文档中自动留下变更提醒,可运行:
- UnityAI 与 Unity 项目的 tracked、staged、unstaged 和 untracked 文件。
- 中文路径和未跟踪 Markdown。
- ESF/General 源码与 CopyTo 生成目标是否同时变化。
- 改动路径对应的建议构建、Unity 和平台验证。
- 可能需要复核的模块文档。
- 本次修改 Markdown 中的相对链接。
```powershell
.\docs\hooks\sync-doc-associations.ps1
```
该脚本只追加、不覆盖,执行后必须 review。
---
`archive-check.ps1` 是兼容包装,新接入统一调用 `workflow-check.ps1`
## 提交检查
提交前可运行:
```powershell
.\docs\hooks\commit-check.ps1 -Message "[entity][docs]: 补充实体池说明"
powershell -NoProfile -ExecutionPolicy Bypass -File hooks/commit-check.ps1 `
-RepositoryRoot <path> `
-Message "[模块][类型]: 描述"
```
检查项包括:
脚本优先检查 staged 文件;没有 staged 文件时检查整个工作树。支持 UnityAI 和目标 Unity 项目,路径映射维护在 `commit-module-mappings.json`
- 提交信息是否符合 `[模块][类型]: 描述` 格式
- 变更文件是否能映射到已知模块
- 模块映射配置来自 [commit-module-mappings.json](commit-module-mappings.json)
允许类型:`feat``fix``docs``refactor``test``chore`
---
提交和推送仍然只在用户明确要求时执行。检查通过不代表可以自动提交。
## 触发方式
## 维护规则
| 触发方式 | 命令/路径 | 说明 |
|---|---|---|
| Codex Stop hook | `.codex/hooks/archive-reminder.ps1` | 若已配置,每次任务结束自动调用 `hooks/archive-check.ps1` |
| Claude Code 手动 | 直接运行脚本 | 任务结束时手动调用 `hooks/archive-check.ps1` |
| 手动脚本 | `hooks/archive-check.ps1` | 直接运行 PowerShell 脚本 |
---
## 新增 Hook 时
1. 把脚本放入 `hooks/`
2. 在本文件中登记。
3. 在 [ai/AI接入入口.md](../ai/AI接入入口.md) 中说明触发方式。
4. 如果涉及提交信息格式,同步更新 [rules/40-工作流清单.md](../rules/40-工作流清单.md)。
---
- 新增稳定模块路径时更新工作流与提交映射。
- 脚本保持 Windows PowerShell 5.1 可运行,不依赖写入仓库的缓存。
- 脚本只输出建议;源码边界警告不自动回退任何文件。
- 新增检查后补无改动、中文路径、无 Unity 工程和双仓库场景验证。
## 文档关联
> 当本文档发生变更时,应同步检查以下关联文档,避免信息不一致。
| 文档 | 关联原因 |
|---|---|
| [ai/AI接入入口.md](../ai/AI接入入口.md) | AI 接入入口引用 hook 共享层 |
| [rules/项目规则入口.md](../rules/项目规则入口.md) | 规则入口索引归档触发机制 |
| [rules/40-工作流清单.md](../rules/40-工作流清单.md) | 提交前检查与工作流清单互相引用 |
| [工作流清单](../rules/40-工作流清单.md) | 检查对应的任务阶段 |
| [文档同步规则](../rules/50-文档同步规则.md) | 文档影响判断 |
| [开发规则](../rules/20-开发规则.md) | 源码与生成代码约束 |
| [AI 接入入口](../ai/AI接入入口.md) | AI 工具调用入口 |

View File

@@ -1,181 +1,10 @@
# hooks/archive-check.ps1
# Check changed .md files and suggest related docs to update.
# This script only outputs suggestions; it does NOT modify any files.
#
# Mechanism:
# For every changed .md file, parse its "## 文档关联" section and extract
# Markdown links. Those links are the documents that may need syncing.
param(
[string]$UnityProjectRoot = '',
[string]$DocsRoot = ''
)
$ErrorActionPreference = "SilentlyContinue"
$scriptPath = $MyInvocation.MyCommand.Path
if (-not [System.IO.Path]::IsPathRooted($scriptPath)) {
$scriptPath = Join-Path (Get-Location) $scriptPath
}
$scriptPath = [System.IO.Path]::GetFullPath($scriptPath)
$scriptDir = Split-Path -Parent $scriptPath
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ".."))
Set-Location $projectRoot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
function Resolve-RelativePath {
param(
[string]$BaseFile,
[string]$LinkPath
)
# Skip anchors, query strings, and external URLs
if ($LinkPath -match '^[a-zA-Z][a-zA-Z0-9+.-]*://') { return $null }
if ([string]::IsNullOrWhiteSpace($LinkPath)) { return $null }
# Drop fragment
$pathOnly = ($LinkPath -split '#')[0]
if ([string]::IsNullOrWhiteSpace($pathOnly)) { return $null }
$baseDir = Split-Path -Parent $BaseFile
$combined = Join-Path $baseDir $pathOnly
try {
$resolved = [System.IO.Path]::GetFullPath($combined)
} catch {
return $null
}
# Convert to relative path from project root, normalizing separators
$fullProjectRoot = [System.IO.Path]::GetFullPath($projectRoot)
if ($resolved.StartsWith($fullProjectRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
$relative = $resolved.Substring($fullProjectRoot.Length).TrimStart('\', '/')
return $relative -replace '\\', '/'
}
return $pathOnly -replace '\\', '/'
}
function Get-DocumentAssociations {
param(
[string]$MdFile
)
$fullPath = Join-Path $projectRoot $MdFile
if (-not (Test-Path $fullPath)) { return @() }
$lines = [System.IO.File]::ReadAllLines($fullPath, [System.Text.Encoding]::UTF8)
$associations = @()
$inSection = $false
$inCodeBlock = $false
foreach ($line in $lines) {
# Toggle fenced code block state (``` or ~~~)
if ($line -match '^\s*```' -or $line -match '^\s*~~~') {
$inCodeBlock = -not $inCodeBlock
continue
}
if ($inCodeBlock) { continue }
# Match exactly '## 文档关联' (not '## 文档关联机制' etc.).
if ($line -match "^##\s+文档关联\s*$") {
$inSection = $true
continue
}
if ($inSection -and $line -match '^##\s+') {
break
}
if ($inSection) {
# Extract markdown links: [text](path)
$regex = '\[(?<text>[^\]]*)\]\((?<path>[^\)]+)\)'
$matches = [regex]::Matches($line, $regex)
foreach ($match in $matches) {
$linkPath = $match.Groups['path'].Value.Trim()
$resolved = Resolve-RelativePath -BaseFile $MdFile -LinkPath $linkPath
if ($resolved) {
$associations += $resolved
}
}
}
}
return $associations | Select-Object -Unique
}
function Test-WildcardMatch {
param(
[string]$Pattern,
[string]$Path
)
$regex = "^" + ($Pattern -replace "\.", "\." -replace "\*", ".*" -replace "\?", ".") + "$"
return $Path -match $regex
}
# ---------------------------------------------------------------------------
# Collect changed .md files (tracked modifications + untracked new files)
# ---------------------------------------------------------------------------
$changedFiles = @(cmd /c "git diff --name-only 2>nul") | Where-Object { $_ }
if ($changedFiles.Count -eq 0) {
$changedFiles = @(cmd /c "git diff --name-only --cached 2>nul") | Where-Object { $_ }
}
# Also include untracked .md files so new documents are checked too
$untrackedFiles = @(cmd /c "git ls-files --others --exclude-standard 2>nul") | Where-Object { $_ }
$allChangedFiles = @($changedFiles) + @($untrackedFiles) | Where-Object { $_ } | Select-Object -Unique
if (-not $allChangedFiles) {
Write-Output "[archive-check] No documentation changes detected (git diff is empty and no untracked .md files)."
exit 0
}
$changedMdFiles = $allChangedFiles | Where-Object { $_ -like "*.md" } | Select-Object -Unique
if ($changedMdFiles.Count -eq 0) {
Write-Output "[archive-check] No .md documentation changes detected."
exit 0
}
# ---------------------------------------------------------------------------
# Build suggestions from each changed file's "## 文档关联" section
# ---------------------------------------------------------------------------
$suggestions = @{}
foreach ($changed in $changedMdFiles) {
$relatedDocs = Get-DocumentAssociations -MdFile $changed
foreach ($related in $relatedDocs) {
# Normalize related path
$relatedNormalized = $related -replace '\\', '/'
if (-not $suggestions.ContainsKey($relatedNormalized)) {
$suggestions[$relatedNormalized] = @()
}
if ($suggestions[$relatedNormalized] -notcontains $changed) {
$suggestions[$relatedNormalized] += $changed
}
}
}
# ---------------------------------------------------------------------------
# Output results
# ---------------------------------------------------------------------------
Write-Output "[archive-check] Changed documentation files:"
foreach ($changed in $changedMdFiles | Sort-Object) {
Write-Output " - $changed"
}
if ($suggestions.Count -eq 0) {
Write-Output ""
Write-Output "[archive-check] No associations found in changed files' `"## 文档关联`" sections."
exit 0
}
Write-Output ""
Write-Output "[archive-check] Related docs that may need updating:"
foreach ($related in $suggestions.Keys | Sort-Object) {
$sources = $suggestions[$related] | Sort-Object
$sourceList = $sources -join ", "
Write-Output " - $related (mentioned by: $sourceList)"
}
Write-Output ""
Write-Output "[archive-check] Please confirm whether to sync-update the above docs."
exit 0
Write-Warning 'archive-check.ps1 is a compatibility entry. Use workflow-check.ps1 for new integrations.'
& (Join-Path $PSScriptRoot 'workflow-check.ps1') `
-UnityProjectRoot $UnityProjectRoot `
-DocsRoot $DocsRoot
exit $LASTEXITCODE

View File

@@ -1,111 +1,106 @@
<#
.SYNOPSIS
提交前共享检查:推断变更文件所属模块,校验提交信息格式。
.DESCRIPTION
读取 git diff 中变更的文件,根据 hooks/commit-module-mappings.json
推断模块,然后校验提交信息是否符合 [模块][类型]: 描述 格式。
.PARAMETER Message
待校验的提交信息。
.EXAMPLE
.\docs\hooks\commit-check.ps1 -Message "[entity][docs]: 补充实体池说明"
#>
param(
[Parameter(Mandatory = $true)]
[string]$Message
[string]$Message,
[string]$RepositoryRoot = ''
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
$RepositoryRoot = (Get-Location).Path
}
$RepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot)
$gitRoot = & git -C $RepositoryRoot rev-parse --show-toplevel 2>$null | Select-Object -First 1
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($gitRoot)) {
Write-Error "Git repository was not found: $RepositoryRoot"
exit 1
}
$RepositoryRoot = [System.IO.Path]::GetFullPath($gitRoot)
$mappingFile = Join-Path $PSScriptRoot 'commit-module-mappings.json'
if (-not (Test-Path $mappingFile)) {
Write-Error "Module mapping file not found: $mappingFile"
$mapping = Get-Content -LiteralPath $mappingFile -Raw -Encoding UTF8 | ConvertFrom-Json
function Get-CommitFiles {
param([string]$Root)
$staged = @(& git -C $Root -c core.quotepath=false diff --cached --name-only --diff-filter=ACMRD)
$staged = @($staged | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($staged.Count -gt 0) {
return $staged
}
$files = @()
$statusLines = & git -C $Root -c core.quotepath=false status --porcelain=v1 --untracked-files=all
foreach ($line in $statusLines) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) {
continue
}
$path = $line.Substring(3).Trim()
if ($path.Contains(' -> ')) {
$path = ($path -split ' -> ')[-1]
}
$files += $path.Replace('\', '/')
}
return @($files | Select-Object -Unique)
}
function Get-Modules {
param([string[]]$Files)
$modules = @()
foreach ($file in $Files) {
foreach ($entry in $mapping.mappings) {
if ($file -match $entry.pattern) {
$modules += $entry.module
break
}
}
}
return @($modules | Select-Object -Unique)
}
if ($Message -notmatch '^\[([^\]]+)\]\[([^\]]+)\]:\s+(.+)$') {
Write-Error 'Commit message format must be: [module][type]: description'
exit 1
}
$mapping = Get-Content $mappingFile -Raw | ConvertFrom-Json
function Get-ChangedFiles {
$staged = (cmd /c "git diff --cached --name-only 2>nul") -split "`r?`n" | Where-Object { $_ }
if (-not $staged) { $staged = @() }
$unstaged = (cmd /c "git diff --name-only 2>nul") -split "`r?`n" | Where-Object { $_ }
if (-not $unstaged) { $unstaged = @() }
$all = @($staged) + @($unstaged) | Select-Object -Unique | Where-Object { $_ }
return $all
$messageModule = $Matches[1]
$messageType = $Matches[2]
$description = $Matches[3]
if ([string]::IsNullOrWhiteSpace($description)) {
Write-Error 'Commit message description is empty.'
exit 1
}
if ($mapping.validTypes -notcontains $messageType) {
Write-Error ("Invalid commit type '{0}'. Allowed types: {1}" -f
$messageType, ($mapping.validTypes -join ', '))
exit 1
}
function Get-ModuleForFile($file) {
foreach ($entry in $mapping.mappings) {
if ($file -match $entry.pattern) {
return $entry.module
}
}
return $null
}
$files = @(Get-CommitFiles $RepositoryRoot)
$modules = @(Get-Modules $files)
function Get-ModulesFromChangedFiles($files) {
$modules = @()
foreach ($file in $files) {
$module = Get-ModuleForFile $file
if ($module) {
$modules += $module
}
}
return $modules | Select-Object -Unique
}
function Test-CommitMessage($message, $modules) {
if ($message -notmatch '^\[([^\]]+)\]\[([^\]]+)\]:\s*(.+)$') {
return @{ IsValid = $false; Reason = '提交信息格式应为:[模块][类型]: 描述' }
}
$msgModule = $Matches[1]
$msgType = $Matches[2]
$msgDesc = $Matches[3]
if (-not $msgDesc) {
return @{ IsValid = $false; Reason = '提交信息缺少描述内容' }
}
if ($mapping.validTypes -notcontains $msgType) {
return @{ IsValid = $false; Reason = "无效的类型 '$msgType'。有效类型:$($mapping.validTypes -join ', ')" }
}
if ($modules -and $modules -notcontains $msgModule) {
Write-Warning "提交信息中的模块 [$msgModule] 与变更文件推断的模块 [$($modules -join ', ')] 不一致,请确认是否 intentional。"
}
return @{ IsValid = $true; Reason = '' }
}
$files = Get-ChangedFiles
if (-not $files) {
Write-Host "未检测到变更文件。" -ForegroundColor Yellow
exit 0
}
$modules = Get-ModulesFromChangedFiles $files
Write-Host "变更文件:"
$files | ForEach-Object { Write-Host " - $_" }
if ($modules) {
Write-Host "推断模块:$($modules -join ', ')"
Write-Output ("Repository: {0}" -f $RepositoryRoot)
if ($files.Count -eq 0) {
Write-Warning 'No staged, unstaged, or untracked files were found. Only the message was validated.'
}
else {
Write-Warning "未能从变更文件推断出模块,请检查提交信息是否手动指定了正确模块。"
Write-Output 'Commit scope files:'
foreach ($file in $files) {
Write-Output (" - {0}" -f $file)
}
}
$result = Test-CommitMessage $Message $modules
if (-not $result.IsValid) {
Write-Error $result.Reason
exit 1
if ($modules.Count -gt 0) {
Write-Output ("Detected modules: {0}" -f ($modules -join ', '))
if ($modules -notcontains $messageModule) {
Write-Warning ("Message module [{0}] does not match detected modules [{1}]. Review the commit scope." -f
$messageModule, ($modules -join ', '))
}
}
else {
Write-Warning 'No path mapping matched. Review the commit module manually.'
}
Write-Host "提交信息格式正确。" -ForegroundColor Green
Write-Output 'Commit message is valid.'
exit 0

View File

@@ -1,81 +1,38 @@
{
{
"mappings": [
{
"pattern": "rules/.*",
"module": "rules"
},
{
"pattern": "modules/frameworks/实体系统.*",
"module": "entity"
},
{
"pattern": "modules/frameworks/UI窗口系统.*",
"module": "ui"
},
{
"pattern": "modules/frameworks/事件系统.*",
"module": "event"
},
{
"pattern": "modules/frameworks/资源管理.*|modules/frameworks/通用框架.*",
"module": "resource"
},
{
"pattern": "modules/frameworks/内存对象管理.*",
"module": "pool"
},
{
"pattern": "modules/frameworks/相机系统.*",
"module": "camera"
},
{
"pattern": "modules/frameworks/场景系统.*",
"module": "scene"
},
{
"pattern": "modules/frameworks/网络系统.*",
"module": "network"
},
{
"pattern": "modules/frameworks/数据配置框架.*",
"module": "config"
},
{
"pattern": "modules/frameworks/技能系统.*",
"module": "skill"
},
{
"pattern": "modules/frameworks/Hotfix启动.*",
"module": "hotfix"
},
{
"pattern": "modules/frameworks/编辑器工具.*|modules/frameworks/第三方插件.*",
"module": "editor"
},
{
"pattern": "hooks/.*",
"module": "hooks"
},
{
"pattern": "ai/.*|AGENTS\\.md|CLAUDE\\.md|RULES\\.md",
"module": "ai"
},
{
"pattern": "specs/.*|plans/.*",
"module": "spec"
},
{
"pattern": "modules/frameworks/编辑器工具/UI工具/.*",
"module": "tools"
},
{
"pattern": "business-reference/.*",
"module": "business-reference"
},
{
"pattern": "README\\.md",
"module": "docs"
}
{ "pattern": "^rules/", "module": "rules" },
{ "pattern": "^ai/|^(AGENTS|CLAUDE|RULES)\\.md$", "module": "ai" },
{ "pattern": "^hooks/", "module": "hooks" },
{ "pattern": "^Tools/CodexUnityMcp/|^modules/frameworks/编辑器工具/MCP工具/", "module": "mcp" },
{ "pattern": "^modules/frameworks/UI窗口系统/|^modules/frameworks/编辑器工具/UI拼接工具\\.md$", "module": "ui" },
{ "pattern": "^modules/frameworks/资源管理/", "module": "resource" },
{ "pattern": "^modules/frameworks/实体系统/", "module": "entity" },
{ "pattern": "^modules/frameworks/事件系统/", "module": "event" },
{ "pattern": "^modules/frameworks/内存对象管理/", "module": "pool" },
{ "pattern": "^modules/frameworks/相机系统/", "module": "camera" },
{ "pattern": "^modules/frameworks/场景系统", "module": "scene" },
{ "pattern": "^modules/frameworks/网络系统", "module": "network" },
{ "pattern": "^modules/frameworks/数据配置框架", "module": "config" },
{ "pattern": "^modules/frameworks/技能系统/", "module": "skill" },
{ "pattern": "^modules/frameworks/Hotfix启动", "module": "hotfix" },
{ "pattern": "^modules/frameworks/编辑器工具/", "module": "editor" },
{ "pattern": "^modules/frameworks/", "module": "framework" },
{ "pattern": "^modules/", "module": "docs" },
{ "pattern": "^business-reference/", "module": "business-reference" },
{ "pattern": "^README\\.md$", "module": "docs" },
{ "pattern": "^Assets/StrayFog/Editor/CopyToX/CopyToHF/(AssetBundle|SpriteAtlas)/", "module": "resource" },
{ "pattern": "^Assets/StrayFog/Core/SFSubPackageV2\\.cs$|^Assets/StrayFog/Editor/Tools/AssestBundle/", "module": "resource" },
{ "pattern": "^Assets/StrayFog/Editor/CopyToX/CopyToHF/UIWindowMgr/", "module": "ui" },
{ "pattern": "^Assets/StrayFog/Editor/", "module": "editor" },
{ "pattern": "^Assets/StrayFog/Core/", "module": "framework" },
{ "pattern": "^Assets/Editor/Command/", "module": "mcp" },
{ "pattern": "^Assets/Game/GameHFScripte/GameFunction/HFDownLoadSubPackage/|^Assets/Game/GameMono/Scripts/GameDllManager\\.cs$", "module": "resource" },
{ "pattern": "^Assets/Game/GameHFScripte/GameFunction/UIWindows/|^Assets/Game/GameMono/Scripts/UIMono/", "module": "ui" },
{ "pattern": "^Assets/Game/GameHFScripte/", "module": "gamehf" },
{ "pattern": "^Assets/Game/GameGeneralScripte/", "module": "general" },
{ "pattern": "^Assets/Game/GameMono/", "module": "mono" },
{ "pattern": "^Assets/Game/GameHFResource/", "module": "asset" }
],
"validTypes": [
"feat",

View File

@@ -1,22 +0,0 @@
# hooks/sync-doc-associations.ps1
# Wrapper for sync_doc_associations.py
# This script appends change reminders to associated .md files.
# It only appends to standard sections; it never overwrites existing content.
$ErrorActionPreference = "Stop"
$scriptPath = $MyInvocation.MyCommand.Path
if (-not [System.IO.Path]::IsPathRooted($scriptPath)) {
$scriptPath = Join-Path (Get-Location) $scriptPath
}
$scriptPath = [System.IO.Path]::GetFullPath($scriptPath)
$scriptDir = Split-Path -Parent $scriptPath
$pythonScript = Join-Path $scriptDir "sync_doc_associations.py"
if (-not (Test-Path $pythonScript)) {
Write-Error "Python script not found: $pythonScript"
exit 1
}
python "$pythonScript"
exit $LASTEXITCODE

View File

@@ -1,218 +0,0 @@
#!/usr/bin/env python3
# hooks/sync_doc_associations.py
#
# 当 md 文件变更时,自动在关联文档的标准位置追加变更提醒:
# - 有 ## 变更记录 表格的模块文档:追加一行变更记录
# - 有 ## 下一步 清单的 progress/tasks 文档:追加一个待检查项
# - 其他文档:追加 ## 最近关联变更 章节
#
# 本脚本只追加、不覆盖正文;执行后请通过 git diff 人工确认。
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXCLUDED_PREFIXES = (
'.claude/', '.codex/', '.git/',
'Library/', 'Temp/', 'Logs/', 'UserSettings/',
'obj/', 'bin/', 'HybridCLRData/',
'Assets/ThirdPlugins/',
'Assets/.WX-WASM-SDK-V2/',
'Packages/',
)
def run_git(args):
result = subprocess.run(
["git"] + args,
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
)
return result.stdout.strip(), result.returncode
def get_changed_md_files():
"""获取 tracked 改动 + untracked 新 md 文件。"""
changed, code = run_git(["diff", "--name-only"])
if code != 0 or not changed:
changed = ""
untracked, code = run_git(["ls-files", "--others", "--exclude-standard"])
if code == 0 and untracked:
changed = (changed + "\n" + untracked).strip()
if not changed:
return []
files = [line.strip() for line in changed.splitlines() if line.strip().endswith(".md")]
files = [f for f in files if not f.startswith(EXCLUDED_PREFIXES)]
return sorted(set(files))
def resolve_link(source_file: Path, link: str):
"""解析相对链接为项目相对路径。"""
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", link):
return None
if link.startswith("#"):
return None
link = link.split("#")[0]
if not link:
return None
target = (source_file.parent / link).resolve()
try:
target.relative_to(PROJECT_ROOT)
except ValueError:
return None
rel = target.relative_to(PROJECT_ROOT)
return str(rel).replace("\\", "/")
def extract_associations(md_path: Path):
"""读取 md 的 ## 文档关联 章节,返回关联文件相对路径列表。"""
content = md_path.read_text(encoding="utf-8")
in_section = False
targets = []
for line in content.splitlines():
if re.match(r"^##\s+文档关联\s*$", line):
in_section = True
continue
if in_section and re.match(r"^##\s+", line):
break
if in_section:
for _, link in re.findall(r"\[([^\]]*)\]\(([^\)]+)\)", line):
resolved = resolve_link(md_path, link)
if resolved:
targets.append(resolved)
return targets
def find_section_range(lines, title_re):
"""返回 [start, end) 行号end 为下一个 ## 标题或文件末尾。"""
start = None
for i, line in enumerate(lines):
if re.match(title_re, line):
start = i
continue
if start is not None and re.match(r"^##\s+", line):
return start, i
if start is not None:
return start, len(lines)
return None, None
def append_changelog(target_path: Path, source_rel: str, date_str: str) -> bool:
"""在 ## 变更记录 表格追加一行;成功返回 True。"""
content = target_path.read_text(encoding="utf-8")
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+变更记录\s*$")
if start is None:
return False
# 找到表格最后一行(跳过标题和分隔线)
insert_pos = end
for i in range(start + 1, end):
if lines[i].strip().startswith("|") and not re.match(r"^\|[-:\s|]+\|$", lines[i].strip()):
insert_pos = i + 1
new_line = f"| {date_str} | [{source_rel}](../../{source_rel}) 发生变更 | 待补充影响范围 | 待确认 |"
lines.insert(insert_pos, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
def append_next_step(target_path: Path, source_rel: str) -> bool:
"""在 ## 下一步 清单追加一个待检查项;成功返回 True。"""
content = target_path.read_text(encoding="utf-8")
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+下一步\s*$")
if start is None:
return False
new_line = f"- [ ] 检查 [{source_rel}](../../{source_rel}) 的变更是否影响本文档"
# 避免重复
for line in lines[start:end]:
if source_rel in line and "变更是否影响本文档" in line:
return True # 已存在,视为成功但不写入
lines.insert(end, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
def append_recent_changes_section(target_path: Path, source_rel: str, date_str: str) -> bool:
"""在没有 变更记录/下一步 的文档末尾追加 ## 最近关联变更。"""
content = target_path.read_text(encoding="utf-8")
# 生成从 target 到 source 的相对链接
try:
link = os.path.relpath(PROJECT_ROOT / source_rel, target_path.parent).replace("\\", "/")
except ValueError:
link = source_rel
if "## 最近关联变更" in content:
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+最近关联变更\s*$")
if start is not None:
new_line = f"- {date_str}:关联文档 [{source_rel}]({link}) 发生变更,需同步检查。"
lines.insert(end, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
section = (
"\n---\n\n"
"## 最近关联变更\n\n"
f"- {date_str}:关联文档 [{source_rel}]({link}) 发生变更,需同步检查。\n"
)
target_path.write_text(content.rstrip() + section, encoding="utf-8")
return True
def sync_associations():
changed_files = get_changed_md_files()
if not changed_files:
print("[sync-doc-assoc] No .md changes detected.")
return 0
date_str = datetime.now().strftime("%Y-%m-%d")
modified = []
for source_rel in changed_files:
source_path = PROJECT_ROOT / source_rel
if not source_path.exists():
continue
targets = extract_associations(source_path)
for target_rel in targets:
target_path = PROJECT_ROOT / target_rel
if not target_path.is_file():
# Skip directories, globs, or missing targets
continue
if target_rel == source_rel:
continue
done = (
append_changelog(target_path, source_rel, date_str)
or append_next_step(target_path, source_rel)
or append_recent_changes_section(target_path, source_rel, date_str)
)
if done:
modified.append((source_rel, target_rel))
if not modified:
print("[sync-doc-assoc] No associated docs need updating.")
return 0
print("[sync-doc-assoc] Updated associated docs:")
for source, target in modified:
print(f" - {source} -> {target}")
print("\n[sync-doc-assoc] Please review the changes with git diff before committing.")
return 0
if __name__ == "__main__":
sys.exit(sync_associations())

311
hooks/workflow-check.ps1 Normal file
View File

@@ -0,0 +1,311 @@
param(
[string]$UnityProjectRoot = '',
[string]$DocsRoot = ''
)
$ErrorActionPreference = 'Stop'
function Get-FullPath {
param([string]$Path, [string]$BasePath)
if ([string]::IsNullOrWhiteSpace($Path)) {
return $null
}
if (-not [System.IO.Path]::IsPathRooted($Path)) {
$Path = Join-Path $BasePath $Path
}
return [System.IO.Path]::GetFullPath($Path)
}
function Test-UnityProject {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return $false
}
return (Test-Path -LiteralPath (Join-Path $Path 'Assets')) -and
(Test-Path -LiteralPath (Join-Path $Path 'ProjectSettings/ProjectVersion.txt'))
}
function Resolve-UnityProject {
param([string]$ExplicitPath, [string]$DocumentationRoot)
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
$resolved = Get-FullPath $ExplicitPath (Get-Location).Path
if (-not (Test-UnityProject $resolved)) {
throw "Unity project was not found: $resolved"
}
return $resolved
}
if (-not [string]::IsNullOrWhiteSpace($env:UNITY_PROJECT_ROOT)) {
$resolved = Get-FullPath $env:UNITY_PROJECT_ROOT (Get-Location).Path
if (-not (Test-UnityProject $resolved)) {
throw "UNITY_PROJECT_ROOT is not a Unity project: $resolved"
}
return $resolved
}
$current = [System.IO.Path]::GetFullPath((Get-Location).Path)
if (Test-UnityProject $current) {
return $current
}
$parent = Split-Path -Parent $DocumentationRoot
$candidates = @()
if (Test-Path -LiteralPath $parent) {
$directories = Get-ChildItem -LiteralPath $parent -Directory -ErrorAction SilentlyContinue
foreach ($directory in $directories) {
if (Test-UnityProject $directory.FullName) {
$candidates += $directory.FullName
}
}
}
if ($candidates.Count -eq 1) {
return [System.IO.Path]::GetFullPath($candidates[0])
}
if ($candidates.Count -gt 1) {
Write-Warning 'Multiple sibling Unity projects were found. Use -UnityProjectRoot to select one.'
}
return $null
}
function Get-GitRoot {
param([string]$Path)
$result = & git -C $Path rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($result)) {
return $null
}
return [System.IO.Path]::GetFullPath(($result | Select-Object -First 1))
}
function Get-GitChanges {
param([string]$RepositoryRoot, [string]$RepositoryName)
$result = @()
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
return $result
}
$lines = & git -C $RepositoryRoot -c core.quotepath=false status --porcelain=v1 --untracked-files=all
if ($LASTEXITCODE -ne 0) {
throw "Could not read git status: $RepositoryRoot"
}
foreach ($line in $lines) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) {
continue
}
$path = $line.Substring(3).Trim()
if ($path.Contains(' -> ')) {
$path = ($path -split ' -> ')[-1]
}
$result += [pscustomobject]@{
Repository = $RepositoryName
Root = $RepositoryRoot
Status = $line.Substring(0, 2)
Path = $path.Replace('\', '/')
}
}
return $result
}
function Show-Changes {
param([string]$Title, [object[]]$Changes)
Write-Output ""
Write-Output "[$Title]"
if ($Changes.Count -eq 0) {
Write-Output ' Clean'
return
}
foreach ($change in $Changes) {
Write-Output (" {0} {1}" -f $change.Status, $change.Path)
}
}
function Get-MarkdownLinkFailures {
param([string]$DocumentationRoot, [object[]]$Changes)
$failures = @()
$markdownChanges = $Changes | Where-Object { $_.Path -like '*.md' -and $_.Status -notmatch 'D' }
foreach ($change in $markdownChanges) {
$fullPath = Join-Path $DocumentationRoot $change.Path
if (-not (Test-Path -LiteralPath $fullPath)) {
continue
}
$content = [System.IO.File]::ReadAllText($fullPath, [System.Text.Encoding]::UTF8)
$matches = [regex]::Matches($content, '!?(?:\[[^\]]*\])\((?<path>[^\)]+)\)')
foreach ($match in $matches) {
$link = $match.Groups['path'].Value.Trim()
if ([string]::IsNullOrWhiteSpace($link) -or $link.StartsWith('#') -or
$link -match '^[a-zA-Z][a-zA-Z0-9+.-]*://' -or $link -match '^mailto:') {
continue
}
if ($link.StartsWith('<') -and $link.EndsWith('>')) {
$link = $link.Substring(1, $link.Length - 2)
}
$pathOnly = ($link -split '#')[0]
if ([string]::IsNullOrWhiteSpace($pathOnly)) {
continue
}
$decoded = [System.Uri]::UnescapeDataString($pathOnly)
$target = Get-FullPath $decoded (Split-Path -Parent $fullPath)
if (-not (Test-Path -LiteralPath $target)) {
$failures += [pscustomobject]@{
Source = $change.Path
Link = $link
}
}
}
}
return $failures
}
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
if ([string]::IsNullOrWhiteSpace($DocsRoot)) {
$DocsRoot = Split-Path -Parent $scriptRoot
}
$DocsRoot = Get-FullPath $DocsRoot (Get-Location).Path
$docsGitRoot = Get-GitRoot $DocsRoot
if ([string]::IsNullOrWhiteSpace($docsGitRoot)) {
throw "Documentation git repository was not found: $DocsRoot"
}
$DocsRoot = $docsGitRoot
$resolvedUnityRoot = Resolve-UnityProject $UnityProjectRoot $DocsRoot
$unityGitRoot = $null
if (-not [string]::IsNullOrWhiteSpace($resolvedUnityRoot)) {
$unityGitRoot = Get-GitRoot $resolvedUnityRoot
if ([string]::IsNullOrWhiteSpace($unityGitRoot)) {
throw "Unity project is not a git repository: $resolvedUnityRoot"
}
}
$docsChanges = @(Get-GitChanges $DocsRoot 'UnityAI')
$unityChanges = @()
if (-not [string]::IsNullOrWhiteSpace($unityGitRoot)) {
$unityChanges = @(Get-GitChanges $unityGitRoot (Split-Path -Leaf $resolvedUnityRoot))
}
Write-Output '[workflow-check] Read-only StrayFog workflow check'
Write-Output ("Documentation repository: {0}" -f $DocsRoot)
if ($resolvedUnityRoot) {
Write-Output ("Unity project: {0}" -f $resolvedUnityRoot)
$versionFile = Join-Path $resolvedUnityRoot 'ProjectSettings/ProjectVersion.txt'
$versionLine = Get-Content -LiteralPath $versionFile -Encoding UTF8 |
Where-Object { $_ -match '^m_EditorVersion:' } | Select-Object -First 1
if ($versionLine) {
Write-Output ("Unity version: {0}" -f (($versionLine -split ':', 2)[1].Trim()))
}
}
else {
Write-Warning 'Unity project was not found. Running documentation-only checks.'
}
Show-Changes 'UnityAI changes' $docsChanges
Show-Changes 'Unity project changes' $unityChanges
$unityPaths = @($unityChanges | ForEach-Object { $_.Path })
$copyRelationships = @(
[pscustomobject]@{
Name = 'CopyToHF'
SourcePattern = '^Assets/StrayFog/Editor/CopyToX/CopyToHF/'
TargetPattern = '^Assets/Game/GameHFScripte/CopyToHF/'
GeneratedName = 'HF'
},
[pscustomobject]@{
Name = 'CopyToGeneral'
SourcePattern = '^Assets/StrayFog/Editor/CopyToX/CopyToGeneral/'
TargetPattern = '^Assets/Game/GameGeneralScripte/CopyToGeneral/'
GeneratedName = 'General'
}
)
Write-Output ''
Write-Output '[Source and generation boundary]'
$relationshipDetected = $false
foreach ($relationship in $copyRelationships) {
$hasSource = @($unityPaths | Where-Object {
$_ -match $relationship.SourcePattern
}).Count -gt 0
$hasTarget = @($unityPaths | Where-Object {
$_ -match $relationship.TargetPattern
}).Count -gt 0
if (-not $hasSource -and -not $hasTarget) {
continue
}
$relationshipDetected = $true
if ($hasSource -and -not $hasTarget) {
Write-Warning ("{0} source changed but generated target did not change. Run CopyToX and inspect the generated diff." -f
$relationship.Name)
}
elseif ($hasTarget -and -not $hasSource) {
Write-Warning ("Generated {0} files changed without {1} source changes. Confirm that generated files were not edited directly." -f
$relationship.GeneratedName, $relationship.Name)
}
else {
Write-Output (" Source and generated target changes were both detected for {0}." -f
$relationship.Name)
}
}
if (-not $relationshipDetected) {
Write-Output ' No CopyToX source/target relationship was detected.'
}
$mappingPath = Join-Path $scriptRoot 'workflow-path-mappings.json'
$mapping = Get-Content -LiteralPath $mappingPath -Raw -Encoding UTF8 | ConvertFrom-Json
$documents = @{}
$verifications = @{}
foreach ($path in $unityPaths) {
foreach ($entry in $mapping.mappings) {
if ($path -match $entry.pattern) {
$documents[$entry.document] = $true
$verifications[$entry.verification] = $true
}
}
}
Write-Output ''
Write-Output '[Suggested verification]'
if ($verifications.Count -eq 0) {
Write-Output ' No Unity verification suggestions for the current diff.'
}
else {
foreach ($item in $verifications.Keys | Sort-Object) {
Write-Output (" - {0}" -f $item)
}
}
Write-Output ''
Write-Output '[Documentation impact]'
if ($documents.Count -eq 0) {
Write-Output ' No module document mapping matched the Unity diff.'
}
else {
foreach ($document in $documents.Keys | Sort-Object) {
$changed = @($docsChanges | Where-Object { $_.Path -eq $document }).Count -gt 0
$marker = if ($changed) { 'updated' } else { 'review if long-term behavior changed' }
Write-Output (" - {0} ({1})" -f $document, $marker)
}
}
$linkFailures = @(Get-MarkdownLinkFailures $DocsRoot $docsChanges)
Write-Output ''
Write-Output '[Markdown links]'
if ($linkFailures.Count -eq 0) {
Write-Output ' Changed Markdown links are valid.'
}
else {
foreach ($failure in $linkFailures) {
Write-Error ("Broken link in {0}: {1}" -f $failure.Source, $failure.Link)
}
exit 1
}
Write-Output ''
Write-Output '[workflow-check] Completed without writing files.'
exit 0

View File

@@ -0,0 +1,59 @@
{
"mappings": [
{
"pattern": "^Assets/StrayFog/Editor/CopyToX/CopyToHF/(AssetBundle|SpriteAtlas)/",
"document": "modules/frameworks/资源管理/README.md",
"verification": "执行 CopyToX检查 HF 生成差异,构建 GameHF并验证 Unity Console。"
},
{
"pattern": "^Assets/StrayFog/Core/SFSubPackageV2\\.cs$|^Assets/StrayFog/Editor/Tools/AssestBundle/",
"document": "modules/frameworks/资源管理/README.md",
"verification": "构建 StrayFogCore 或 SFEditor并验证 V2 清单、MD5、状态和目标平台下载。"
},
{
"pattern": "^Assets/Game/GameHFScripte/GameFunction/HFDownLoadSubPackage/|^Assets/Game/GameMono/Scripts/GameDllManager\\.cs$",
"document": "modules/frameworks/资源管理/使用指南.md",
"verification": "构建 GameHF 或 SFMono验证 Bootstrap 门禁、差异回调和普通资源更新。"
},
{
"pattern": "^Assets/StrayFog/Editor/CopyToX/CopyToHF/UIWindowMgr/|^Assets/Game/GameHFScripte/GameFunction/UIWindows/|^Assets/Game/GameMono/Scripts/UIMono/",
"document": "modules/frameworks/UI窗口系统/README.md",
"verification": "构建 GameHF/SFMono刷新 Unity检查 Prefab 层级、列表回收和实际显示。"
},
{
"pattern": "^Assets/Editor/Command/|^Tools/CodexUnityMcp/",
"document": "modules/frameworks/编辑器工具/MCP工具/README.md",
"verification": "检查 Unity Editor 编译、unity_health、目标命令和 Console 日志。"
},
{
"pattern": "^Assets/Game/GameHFResource/.+\\.prefab$",
"document": "modules/frameworks/UI窗口系统/界面拼接.md",
"verification": "刷新 Unity检查 RectTransform、组件状态、资源引用和运行时视觉结果。"
},
{
"pattern": "^Assets/StrayFog/Core/",
"document": "modules/frameworks/通用框架/README.md",
"verification": "构建 StrayFogCore并检查 Unity Console。"
},
{
"pattern": "^Assets/StrayFog/Editor/",
"document": "modules/frameworks/编辑器工具/README.md",
"verification": "构建 SFEditor 或触发 Unity 编译,并验证对应菜单或生成器。"
},
{
"pattern": "^Assets/Game/GameGeneralScripte/",
"document": "modules/frameworks/通用框架/README.md",
"verification": "构建 GameGeneral生成目录变化时确认 CopyToGeneral 源码同步。"
},
{
"pattern": "^Assets/Game/GameHFScripte/",
"document": "modules/模块索引.md",
"verification": "构建 GameHF并检查 Unity Console。"
},
{
"pattern": "^Assets/Game/GameMono/",
"document": "modules/模块索引.md",
"verification": "构建 SFMono并检查 Unity Console。"
}
]
}