[docs][refactor]: 将旧 01~06 中文教程合并整理为 modules/frameworks 文件夹结构
- 删除 01~05 旧中文教程目录 - 将 03 核心功能教程拆分为各 framework 模块的子文档 - 06 工具脚本移入 modules/frameworks/编辑器工具/UI工具/ - 非框架内容合并进 rules/20-开发规则.md 与 rules/40-工作流清单.md - 更新所有交叉链接与文档关联表 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
hooks/Hook共享层.md
Normal file
89
hooks/Hook共享层.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Hook 共享层
|
||||
|
||||
> 本目录存放 UnityAI / StrayFog 框架知识库共享的 hook 脚本和规则。
|
||||
>
|
||||
> 各 AI 工具自己的私有目录只放适配器,不重复维护 hook 逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 脚本清单
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|---|---|
|
||||
| [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
|
||||
.\docs\hooks\sync-doc-associations.ps1
|
||||
```
|
||||
|
||||
该脚本只追加、不覆盖,执行后必须 review。
|
||||
|
||||
---
|
||||
|
||||
## 提交检查
|
||||
|
||||
提交前可运行:
|
||||
|
||||
```powershell
|
||||
.\docs\hooks\commit-check.ps1 -Message "[entity][docs]: 补充实体池说明"
|
||||
```
|
||||
|
||||
检查项包括:
|
||||
|
||||
- 提交信息是否符合 `[模块][类型]: 描述` 格式
|
||||
- 变更文件是否能映射到已知模块
|
||||
- 模块映射配置来自 [commit-module-mappings.json](commit-module-mappings.json)
|
||||
|
||||
---
|
||||
|
||||
## 触发方式
|
||||
|
||||
| 触发方式 | 命令/路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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)。
|
||||
|
||||
---
|
||||
|
||||
## 文档关联
|
||||
|
||||
> 当本文档发生变更时,应同步检查以下关联文档,避免信息不一致。
|
||||
|
||||
| 文档 | 关联原因 |
|
||||
|---|---|
|
||||
| [ai/AI接入入口.md](../ai/AI接入入口.md) | AI 接入入口引用 hook 共享层 |
|
||||
| [rules/项目规则入口.md](../rules/项目规则入口.md) | 规则入口索引归档触发机制 |
|
||||
| [rules/40-工作流清单.md](../rules/40-工作流清单.md) | 提交前检查与工作流清单互相引用 |
|
||||
181
hooks/archive-check.ps1
Normal file
181
hooks/archive-check.ps1
Normal file
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
|
||||
$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
|
||||
111
hooks/commit-check.ps1
Normal file
111
hooks/commit-check.ps1
Normal file
@@ -0,0 +1,111 @@
|
||||
<#
|
||||
.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
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$mappingFile = Join-Path $PSScriptRoot 'commit-module-mappings.json'
|
||||
if (-not (Test-Path $mappingFile)) {
|
||||
Write-Error "Module mapping file not found: $mappingFile"
|
||||
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
|
||||
}
|
||||
|
||||
function Get-ModuleForFile($file) {
|
||||
foreach ($entry in $mapping.mappings) {
|
||||
if ($file -match $entry.pattern) {
|
||||
return $entry.module
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
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 ', ')"
|
||||
}
|
||||
else {
|
||||
Write-Warning "未能从变更文件推断出模块,请检查提交信息是否手动指定了正确模块。"
|
||||
}
|
||||
|
||||
$result = Test-CommitMessage $Message $modules
|
||||
if (-not $result.IsValid) {
|
||||
Write-Error $result.Reason
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "提交信息格式正确。" -ForegroundColor Green
|
||||
exit 0
|
||||
88
hooks/commit-module-mappings.json
Normal file
88
hooks/commit-module-mappings.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"validTypes": [
|
||||
"feat",
|
||||
"fix",
|
||||
"docs",
|
||||
"refactor",
|
||||
"test",
|
||||
"chore"
|
||||
]
|
||||
}
|
||||
22
hooks/sync-doc-associations.ps1
Normal file
22
hooks/sync-doc-associations.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
# 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
|
||||
218
hooks/sync_doc_associations.py
Normal file
218
hooks/sync_doc_associations.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user