2131 lines
78 KiB
Python
2131 lines
78 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only architecture inventory for AirShelf.
|
|
|
|
The scanner intentionally has no apply/write mode. It emits deterministic JSON to
|
|
stdout and limits all content reads to architecture-relevant, non-sensitive files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import base64
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import zlib
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Callable, Iterable, Sequence
|
|
from urllib.parse import unquote
|
|
|
|
|
|
SCHEMA_VERSION = 1
|
|
MAX_TEXT_BYTES = 1_000_000
|
|
MAX_HASH_BYTES = 10_000_000
|
|
TARGET_DOCUMENTS = (
|
|
"core/backend/ARCHITECTURE.md",
|
|
"core/frontend/ARCHITECTURE.md",
|
|
)
|
|
GIT_SCOPES = ("core/backend", "core/frontend", "k8s/core")
|
|
RELEVANT_PATTERNS = (
|
|
"core/backend/airshelf/settings/*.py",
|
|
"core/backend/airshelf/urls.py",
|
|
"core/backend/apps/*/apps.py",
|
|
"core/backend/apps/*/models.py",
|
|
"core/backend/apps/*/urls.py",
|
|
"core/backend/apps/*/tasks.py",
|
|
"core/backend/apps/ai/providers/*.py",
|
|
"core/backend/requirements.txt",
|
|
"core/backend/Dockerfile",
|
|
"core/backend/docker-entrypoint.sh",
|
|
"k8s/core/*",
|
|
"k8s/core/**/*",
|
|
"core/frontend/package.json",
|
|
"core/frontend/src/App.tsx",
|
|
"core/frontend/src/api.ts",
|
|
"core/frontend/src/types.ts",
|
|
"core/frontend/src/routes/*",
|
|
"core/frontend/src/routes/**/*",
|
|
"core/frontend/src/components/*",
|
|
"core/frontend/src/components/**/*",
|
|
"core/frontend/vite.config.ts",
|
|
"core/frontend/Dockerfile",
|
|
"core/frontend/nginx.conf",
|
|
)
|
|
EXCLUDED_PARTS = frozenset(
|
|
{
|
|
".git",
|
|
".venv",
|
|
"venv",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
".next",
|
|
"coverage",
|
|
"__pycache__",
|
|
".pytest_cache",
|
|
".mypy_cache",
|
|
".ruff_cache",
|
|
".cache",
|
|
"cache",
|
|
"media",
|
|
"uploads",
|
|
"user_uploads",
|
|
"staticfiles",
|
|
"ab_results",
|
|
}
|
|
)
|
|
SENSITIVE_NAMES = frozenset(
|
|
{
|
|
"account.md",
|
|
"credentials.json",
|
|
"service-account.json",
|
|
"secrets.json",
|
|
"id_rsa",
|
|
"id_ed25519",
|
|
}
|
|
)
|
|
SENSITIVE_SUFFIXES = (
|
|
".pem",
|
|
".key",
|
|
".p12",
|
|
".pfx",
|
|
".sqlite",
|
|
".sqlite3",
|
|
".db",
|
|
)
|
|
SYNC_MARKER_RE = re.compile(
|
|
r"<!--\s*architecture-sync-commit:\s*([0-9a-fA-F]{7,40})\s*-->"
|
|
)
|
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
|
TS_EXPORT_RE = re.compile(
|
|
r"\bexport\s+(?:default\s+)?(?:async\s+)?"
|
|
r"(?:const|let|var|function|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)"
|
|
)
|
|
QUOTED_PATH_RE = re.compile(r"[\"'`](/[^\"'`\s]*)[\"'`]")
|
|
MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
|
MARKDOWN_REFERENCE_LINK_RE = re.compile(r"(?m)^\s*\[[^\]]+\]:\s*(\S+)")
|
|
ABSOLUTE_LOCAL_PATH_RE = re.compile(
|
|
r"(?i)(?:\b[A-Z]:[\\/]|file://|/(?:Users|home|var/folders|private/tmp)/)"
|
|
)
|
|
SECRET_CONTENT_PATTERNS = (
|
|
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
|
re.compile(
|
|
r"(?i)\b(?:api[_-]?key|secret|password)\b\s*[:=]\s*[\"']?[A-Za-z0-9_./+=-]{12,}"
|
|
),
|
|
)
|
|
BACKEND_DOCUMENT = "core/backend/ARCHITECTURE.md"
|
|
FRONTEND_DOCUMENT = "core/frontend/ARCHITECTURE.md"
|
|
BACKEND_APP_SECTIONS = {
|
|
"common": "4.1 `common`",
|
|
"accounts": "4.2 `accounts`",
|
|
"products": "4.3 `products`",
|
|
"assets": "4.4 `assets`",
|
|
"projects": "4.5 `projects`",
|
|
"ai": "4.6 `ai`",
|
|
"billing": "4.7 `billing`",
|
|
"ops": "4.8 `ops`",
|
|
"adminpanel": "4.9 `adminpanel`",
|
|
}
|
|
|
|
|
|
class ScanError(RuntimeError):
|
|
"""A safe, user-reportable scanner failure."""
|
|
|
|
|
|
def normalize_path(value: str) -> str:
|
|
path = value.replace("\\", "/").removeprefix("./")
|
|
pure = PurePosixPath(path)
|
|
if not path or pure.is_absolute() or ".." in pure.parts:
|
|
return ""
|
|
return pure.as_posix()
|
|
|
|
|
|
def is_forbidden(path: str) -> bool:
|
|
normalized = normalize_path(path)
|
|
if not normalized:
|
|
return True
|
|
parts = tuple(part.lower() for part in PurePosixPath(normalized).parts)
|
|
name = parts[-1]
|
|
if any(part in EXCLUDED_PARTS for part in parts):
|
|
return True
|
|
if name == ".env" or name.startswith(".env."):
|
|
return True
|
|
if name in SENSITIVE_NAMES or name.endswith(SENSITIVE_SUFFIXES):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_relevant(path: str) -> bool:
|
|
normalized = normalize_path(path)
|
|
return bool(normalized) and not is_forbidden(normalized) and any(
|
|
fnmatch.fnmatchcase(normalized, pattern) for pattern in RELEVANT_PATTERNS
|
|
)
|
|
|
|
|
|
def selected_documents(scope: str) -> tuple[str, ...]:
|
|
if scope == "backend":
|
|
return (BACKEND_DOCUMENT,)
|
|
if scope == "frontend":
|
|
return (FRONTEND_DOCUMENT,)
|
|
return TARGET_DOCUMENTS
|
|
|
|
|
|
def path_in_scope(path: str, scope: str) -> bool:
|
|
if scope == "backend":
|
|
return path.startswith("core/backend/") or path.startswith("k8s/core/")
|
|
if scope == "frontend":
|
|
return path.startswith("core/frontend/")
|
|
return path.startswith(("core/backend/", "core/frontend/", "k8s/core/"))
|
|
|
|
|
|
def filter_entries(entries: Iterable[dict[str, str]], scope: str) -> list[dict[str, str]]:
|
|
return [entry for entry in entries if path_in_scope(entry["path"], scope)]
|
|
|
|
|
|
def safe_candidate(root: Path, relative: str) -> Path | None:
|
|
normalized = normalize_path(relative)
|
|
if not normalized or is_forbidden(normalized):
|
|
return None
|
|
candidate = root.joinpath(*PurePosixPath(normalized).parts)
|
|
try:
|
|
resolved_root = root.resolve(strict=True)
|
|
resolved = candidate.resolve(strict=False)
|
|
resolved.relative_to(resolved_root)
|
|
except (OSError, ValueError):
|
|
return None
|
|
current = candidate
|
|
while current != root:
|
|
if current.is_symlink():
|
|
return None
|
|
current = current.parent
|
|
return candidate
|
|
|
|
|
|
def read_bytes(root: Path, relative: str, warnings: set[str]) -> bytes | None:
|
|
candidate = safe_candidate(root, relative)
|
|
if candidate is None or not candidate.is_file():
|
|
return None
|
|
try:
|
|
size = candidate.stat().st_size
|
|
if size > MAX_TEXT_BYTES:
|
|
warnings.add(f"file_too_large:{normalize_path(relative)}")
|
|
return None
|
|
return candidate.read_bytes()
|
|
except OSError:
|
|
warnings.add(f"file_unreadable:{normalize_path(relative)}")
|
|
return None
|
|
|
|
|
|
def read_text(root: Path, relative: str, warnings: set[str]) -> str | None:
|
|
data = read_bytes(root, relative, warnings)
|
|
if data is None:
|
|
return None
|
|
try:
|
|
return data.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
warnings.add(f"file_not_utf8:{normalize_path(relative)}")
|
|
return None
|
|
|
|
|
|
def file_fingerprint(root: Path, relative: str, warnings: set[str]) -> str | None:
|
|
candidate = safe_candidate(root, relative)
|
|
if candidate is None or not candidate.is_file():
|
|
return None
|
|
try:
|
|
if candidate.stat().st_size > MAX_HASH_BYTES:
|
|
warnings.add(f"fingerprint_file_too_large:{normalize_path(relative)}")
|
|
return None
|
|
digest = hashlib.sha256()
|
|
with candidate.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(131_072), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
except OSError:
|
|
warnings.add(f"fingerprint_unavailable:{normalize_path(relative)}")
|
|
return None
|
|
|
|
|
|
def git(root: Path, arguments: Sequence[str], *, required: bool = True) -> bytes:
|
|
environment = os.environ.copy()
|
|
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-c", "core.quotepath=false", *arguments],
|
|
cwd=root,
|
|
env=environment,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
check=False,
|
|
)
|
|
except OSError as error:
|
|
raise ScanError("git_unavailable") from error
|
|
if result.returncode and required:
|
|
raise ScanError(f"git_command_failed:{arguments[0]}")
|
|
return result.stdout if result.returncode == 0 else b""
|
|
|
|
|
|
def locate_repository(start: Path) -> Path:
|
|
output = git(start, ("rev-parse", "--show-toplevel"))
|
|
try:
|
|
root = Path(output.decode("utf-8").strip()).resolve(strict=True)
|
|
except (UnicodeDecodeError, OSError) as error:
|
|
raise ScanError("repository_root_unavailable") from error
|
|
if not root.is_dir():
|
|
raise ScanError("repository_root_unavailable")
|
|
return root
|
|
|
|
|
|
def decode_git_token(token: bytes) -> str:
|
|
return token.decode("utf-8", errors="replace")
|
|
|
|
|
|
def parse_name_status(
|
|
data: bytes, path_filter: Callable[[str], bool] = is_relevant
|
|
) -> list[dict[str, str]]:
|
|
tokens = [decode_git_token(token) for token in data.split(b"\0") if token]
|
|
entries: list[dict[str, str]] = []
|
|
index = 0
|
|
while index < len(tokens):
|
|
status = tokens[index]
|
|
index += 1
|
|
if index >= len(tokens):
|
|
break
|
|
if status.startswith(("R", "C")):
|
|
old_path = normalize_path(tokens[index])
|
|
index += 1
|
|
if index >= len(tokens):
|
|
break
|
|
new_path = normalize_path(tokens[index])
|
|
index += 1
|
|
if path_filter(new_path):
|
|
entry = {"path": new_path, "status": status}
|
|
if path_filter(old_path):
|
|
entry["old_path"] = old_path
|
|
entries.append(entry)
|
|
elif path_filter(old_path):
|
|
entries.append({"path": old_path, "status": "D"})
|
|
continue
|
|
path = normalize_path(tokens[index])
|
|
index += 1
|
|
if path_filter(path):
|
|
entries.append({"path": path, "status": status})
|
|
return sorted(entries, key=lambda item: (item["path"], item["status"]))
|
|
|
|
|
|
def diff_entries(root: Path, arguments: Sequence[str]) -> list[dict[str, str]]:
|
|
output = git(
|
|
root,
|
|
(*arguments, "--name-status", "--find-renames", "-z", "--", *GIT_SCOPES),
|
|
)
|
|
return parse_name_status(output)
|
|
|
|
|
|
def untracked_entries(root: Path) -> list[dict[str, str]]:
|
|
output = git(
|
|
root,
|
|
("ls-files", "--others", "--exclude-standard", "-z", "--", *GIT_SCOPES),
|
|
)
|
|
entries = []
|
|
for token in output.split(b"\0"):
|
|
if not token:
|
|
continue
|
|
path = normalize_path(decode_git_token(token))
|
|
if is_relevant(path):
|
|
entries.append({"path": path, "status": "??"})
|
|
return sorted(entries, key=lambda item: item["path"])
|
|
|
|
|
|
def all_diff_entries(root: Path, arguments: Sequence[str]) -> list[dict[str, str]]:
|
|
output = git(
|
|
root,
|
|
(*arguments, "--name-status", "--find-renames", "-z", "--"),
|
|
)
|
|
return parse_name_status(output, lambda path: bool(normalize_path(path)))
|
|
|
|
|
|
def all_untracked_entries(root: Path) -> list[dict[str, str]]:
|
|
output = git(root, ("ls-files", "--others", "--exclude-standard", "-z", "--"))
|
|
entries = []
|
|
for token in output.split(b"\0"):
|
|
if not token:
|
|
continue
|
|
path = normalize_path(decode_git_token(token))
|
|
if path:
|
|
entries.append({"path": path, "status": "??"})
|
|
return sorted(entries, key=lambda item: item["path"])
|
|
|
|
|
|
def worktree_guard(root: Path) -> dict[str, Any]:
|
|
groups = (
|
|
("staged", all_diff_entries(root, ("diff", "--cached"))),
|
|
("unstaged", all_diff_entries(root, ("diff",))),
|
|
("untracked", all_untracked_entries(root)),
|
|
)
|
|
states: dict[str, set[str]] = {}
|
|
forbidden_paths: set[str] = set()
|
|
for source, entries in groups:
|
|
for entry in entries:
|
|
paths = [entry["path"]]
|
|
if entry.get("old_path"):
|
|
paths.append(entry["old_path"])
|
|
for path in paths:
|
|
if is_forbidden(path):
|
|
forbidden_paths.add(path)
|
|
continue
|
|
states.setdefault(path, set()).add(f"{source}:{entry['status']}")
|
|
return {
|
|
"files": [
|
|
{"path": path, "states": sorted(values)}
|
|
for path, values in sorted(states.items())
|
|
],
|
|
"forbidden_change_count": len(forbidden_paths),
|
|
}
|
|
|
|
|
|
def verification_error(
|
|
code: str, message: str, suggestion: str, **details: Any
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"code": code,
|
|
"message": message,
|
|
"suggestion": suggestion,
|
|
**details,
|
|
}
|
|
|
|
|
|
def compare_worktree_guards(
|
|
before: dict[str, Any], current: dict[str, Any], allowed_documents: set[str]
|
|
) -> list[dict[str, Any]]:
|
|
errors: list[dict[str, Any]] = []
|
|
before_map = {item["path"]: item["states"] for item in before.get("files", [])}
|
|
current_map = {item["path"]: item["states"] for item in current.get("files", [])}
|
|
for path in sorted(set(before_map) | set(current_map)):
|
|
if path in allowed_documents:
|
|
continue
|
|
if before_map.get(path) != current_map.get(path):
|
|
errors.append(
|
|
verification_error(
|
|
"write_outside_allowlist",
|
|
"检测到计划外文件状态发生变化。",
|
|
"撤销本流程对该文件的修改,或重新生成包含正确范围的新计划。",
|
|
path=path,
|
|
)
|
|
)
|
|
if before.get("forbidden_change_count", 0) != current.get(
|
|
"forbidden_change_count", 0
|
|
):
|
|
errors.append(
|
|
verification_error(
|
|
"sensitive_or_excluded_path_changed",
|
|
"检测到敏感或排除路径的工作区状态发生变化。",
|
|
"停止文档同步并由用户单独检查敏感或排除文件。",
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def current_head(root: Path) -> str | None:
|
|
output = git(root, ("rev-parse", "--verify", "HEAD"), required=False)
|
|
value = output.decode("ascii", errors="ignore").strip().lower()
|
|
return value if re.fullmatch(r"[0-9a-f]{40}", value) else None
|
|
|
|
|
|
def marker_is_usable(root: Path, marker: str, head: str | None) -> bool:
|
|
if head is None:
|
|
return False
|
|
exists = subprocess.run(
|
|
["git", "cat-file", "-e", f"{marker}^{{commit}}"],
|
|
cwd=root,
|
|
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode == 0
|
|
if not exists:
|
|
return False
|
|
return subprocess.run(
|
|
["git", "merge-base", "--is-ancestor", marker, head],
|
|
cwd=root,
|
|
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode == 0
|
|
|
|
|
|
def document_states(
|
|
root: Path, warnings: set[str], documents: Sequence[str] = TARGET_DOCUMENTS
|
|
) -> tuple[dict[str, dict[str, Any]], dict[str, str | None]]:
|
|
states: dict[str, dict[str, Any]] = {}
|
|
markers: dict[str, str | None] = {}
|
|
for path in documents:
|
|
data = read_bytes(root, path, warnings)
|
|
if data is None:
|
|
states[path] = {"exists": False, "headings": [], "sha256": None}
|
|
markers[path] = None
|
|
warnings.add(f"target_document_missing:{path}")
|
|
continue
|
|
try:
|
|
text = data.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
text = ""
|
|
warnings.add(f"target_document_not_utf8:{path}")
|
|
marker_match = SYNC_MARKER_RE.search(text)
|
|
marker = marker_match.group(1).lower() if marker_match else None
|
|
markers[path] = marker
|
|
headings = [
|
|
{"level": len(match.group(1)), "title": match.group(2).strip()}
|
|
for match in HEADING_RE.finditer(text)
|
|
]
|
|
states[path] = {
|
|
"exists": True,
|
|
"headings": headings,
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"sync_commit": marker,
|
|
}
|
|
return states, markers
|
|
|
|
|
|
def walk_relevant_files(root: Path) -> list[str]:
|
|
roots = (
|
|
"core/backend/airshelf",
|
|
"core/backend/apps",
|
|
"core/backend/requirements.txt",
|
|
"core/backend/Dockerfile",
|
|
"core/backend/docker-entrypoint.sh",
|
|
"core/frontend/package.json",
|
|
"core/frontend/src",
|
|
"core/frontend/vite.config.ts",
|
|
"core/frontend/Dockerfile",
|
|
"core/frontend/nginx.conf",
|
|
"k8s/core",
|
|
)
|
|
paths: set[str] = set()
|
|
for relative in roots:
|
|
candidate = safe_candidate(root, relative)
|
|
if candidate is None or not candidate.exists():
|
|
continue
|
|
candidates: Iterable[Path] = (candidate,) if candidate.is_file() else candidate.rglob("*")
|
|
for item in candidates:
|
|
if not item.is_file() or item.is_symlink():
|
|
continue
|
|
try:
|
|
rel = item.relative_to(root).as_posix()
|
|
except ValueError:
|
|
continue
|
|
if is_relevant(rel):
|
|
paths.add(rel)
|
|
return sorted(paths)
|
|
|
|
|
|
def scope_content_fingerprint(
|
|
root: Path, scope: str, warnings: set[str]
|
|
) -> tuple[str, int]:
|
|
paths = [path for path in walk_relevant_files(root) if path_in_scope(path, scope)]
|
|
fingerprints = {path: file_fingerprint(root, path, warnings) for path in paths}
|
|
canonical = json.dumps(
|
|
fingerprints, ensure_ascii=True, sort_keys=True, separators=(",", ":")
|
|
)
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20], len(paths)
|
|
|
|
|
|
def parse_python(root: Path, relative: str, warnings: set[str]) -> ast.Module | None:
|
|
text = read_text(root, relative, warnings)
|
|
if text is None:
|
|
return None
|
|
try:
|
|
return ast.parse(text, filename=relative)
|
|
except SyntaxError:
|
|
warnings.add(f"python_parse_failed:{relative}")
|
|
return None
|
|
|
|
|
|
def dotted_name(node: ast.AST) -> str:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
parent = dotted_name(node.value)
|
|
return f"{parent}.{node.attr}" if parent else node.attr
|
|
return ""
|
|
|
|
|
|
def model_classes(root: Path, relative: str, warnings: set[str]) -> list[str]:
|
|
tree = parse_python(root, relative, warnings)
|
|
if tree is None:
|
|
return []
|
|
classes = []
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.ClassDef):
|
|
continue
|
|
bases = [dotted_name(base).split(".")[-1] for base in node.bases]
|
|
if any(base.lower().endswith("model") or base == "AbstractUser" for base in bases):
|
|
classes.append(node.name)
|
|
return sorted(classes)
|
|
|
|
|
|
def django_routes(root: Path, relative: str, warnings: set[str]) -> list[dict[str, str | None]]:
|
|
tree = parse_python(root, relative, warnings)
|
|
if tree is None:
|
|
return []
|
|
routes: list[dict[str, str | None]] = []
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call) or not node.args:
|
|
continue
|
|
if dotted_name(node.func).split(".")[-1] not in {"path", "re_path"}:
|
|
continue
|
|
first = node.args[0]
|
|
if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
|
|
continue
|
|
included: str | None = None
|
|
if len(node.args) > 1 and isinstance(node.args[1], ast.Call):
|
|
call = node.args[1]
|
|
if dotted_name(call.func).split(".")[-1] == "include" and call.args:
|
|
value = call.args[0]
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
included = value.value
|
|
routes.append({"include": included, "path": first.value})
|
|
return sorted(routes, key=lambda item: (item["path"], item["include"] or ""))
|
|
|
|
|
|
def celery_tasks(root: Path, relative: str, warnings: set[str]) -> list[str]:
|
|
tree = parse_python(root, relative, warnings)
|
|
if tree is None:
|
|
return []
|
|
tasks = []
|
|
for node in tree.body:
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
decorator_names = []
|
|
for decorator in node.decorator_list:
|
|
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
decorator_names.append(dotted_name(target).split(".")[-1])
|
|
if any(name in {"shared_task", "task", "periodic_task"} for name in decorator_names):
|
|
tasks.append(node.name)
|
|
return sorted(tasks)
|
|
|
|
|
|
def top_level_classes(root: Path, relative: str, warnings: set[str]) -> list[str]:
|
|
tree = parse_python(root, relative, warnings)
|
|
if tree is None:
|
|
return []
|
|
return sorted(node.name for node in tree.body if isinstance(node, ast.ClassDef))
|
|
|
|
|
|
def assignment_names(tree: ast.Module) -> set[str]:
|
|
names: set[str] = set()
|
|
for node in tree.body:
|
|
targets: list[ast.AST] = []
|
|
if isinstance(node, ast.Assign):
|
|
targets.extend(node.targets)
|
|
elif isinstance(node, ast.AnnAssign):
|
|
targets.append(node.target)
|
|
for target in targets:
|
|
if isinstance(target, ast.Name):
|
|
names.add(target.id)
|
|
return names
|
|
|
|
|
|
def requirement_names(root: Path, warnings: set[str]) -> list[str]:
|
|
text = read_text(root, "core/backend/requirements.txt", warnings)
|
|
if text is None:
|
|
return []
|
|
names = set()
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith(("#", "-")):
|
|
continue
|
|
match = re.match(r"([A-Za-z0-9_.-]+)", stripped)
|
|
if match:
|
|
names.add(match.group(1).lower())
|
|
return sorted(names)
|
|
|
|
|
|
def collect_backend(root: Path, warnings: set[str]) -> dict[str, Any]:
|
|
apps_root = root / "core" / "backend" / "apps"
|
|
apps = []
|
|
if apps_root.is_dir() and not apps_root.is_symlink():
|
|
apps = sorted(
|
|
item.name
|
|
for item in apps_root.iterdir()
|
|
if item.is_dir()
|
|
and not item.is_symlink()
|
|
and not is_forbidden(f"core/backend/apps/{item.name}")
|
|
and ((item / "apps.py").is_file() or (item / "__init__.py").is_file())
|
|
)
|
|
|
|
models: dict[str, list[str]] = {}
|
|
app_routes: dict[str, list[dict[str, str | None]]] = {}
|
|
tasks: dict[str, list[str]] = {}
|
|
for app in apps:
|
|
model_path = f"core/backend/apps/{app}/models.py"
|
|
route_path = f"core/backend/apps/{app}/urls.py"
|
|
task_path = f"core/backend/apps/{app}/tasks.py"
|
|
if safe_candidate(root, model_path) and (root / model_path).is_file():
|
|
models[app] = model_classes(root, model_path, warnings)
|
|
if safe_candidate(root, route_path) and (root / route_path).is_file():
|
|
app_routes[app] = django_routes(root, route_path, warnings)
|
|
if safe_candidate(root, task_path) and (root / task_path).is_file():
|
|
tasks[app] = celery_tasks(root, task_path, warnings)
|
|
|
|
provider_root = root / "core" / "backend" / "apps" / "ai" / "providers"
|
|
providers = []
|
|
if provider_root.is_dir() and not provider_root.is_symlink():
|
|
for file in sorted(provider_root.glob("*.py"), key=lambda item: item.name):
|
|
if file.name == "__init__.py" or file.is_symlink():
|
|
continue
|
|
relative = file.relative_to(root).as_posix()
|
|
providers.append(
|
|
{"classes": top_level_classes(root, relative, warnings), "path": relative}
|
|
)
|
|
|
|
settings = {}
|
|
settings_root = root / "core" / "backend" / "airshelf" / "settings"
|
|
if settings_root.is_dir() and not settings_root.is_symlink():
|
|
important = {
|
|
"AUTHENTICATION_BACKENDS",
|
|
"AUTH_USER_MODEL",
|
|
"CACHES",
|
|
"DATABASES",
|
|
"INSTALLED_APPS",
|
|
"MIDDLEWARE",
|
|
"REST_FRAMEWORK",
|
|
"STORAGES",
|
|
}
|
|
prefixes = ("AI_", "ARK_", "CELERY_", "MODEL_", "REDIS_", "TOKENSSR_", "TOS_", "YUNQI_")
|
|
for file in sorted(settings_root.glob("*.py"), key=lambda item: item.name):
|
|
if file.name == "__init__.py" or file.is_symlink():
|
|
continue
|
|
relative = file.relative_to(root).as_posix()
|
|
tree = parse_python(root, relative, warnings)
|
|
symbols = [] if tree is None else sorted(
|
|
name for name in assignment_names(tree) if name in important or name.startswith(prefixes)
|
|
)
|
|
settings[file.stem] = symbols
|
|
|
|
base_settings = read_text(root, "core/backend/airshelf/settings/base.py", warnings) or ""
|
|
configured_apps = sorted(set(re.findall(r"[\"']apps\.([a-zA-Z0-9_]+)", base_settings)))
|
|
deployment_candidates = (
|
|
"core/backend/Dockerfile",
|
|
"core/backend/docker-entrypoint.sh",
|
|
)
|
|
deployment_files = [
|
|
path for path in deployment_candidates if (safe_candidate(root, path) or Path()).is_file()
|
|
]
|
|
k8s_files = [path for path in walk_relevant_files(root) if path.startswith("k8s/core/")]
|
|
return {
|
|
"api_routes": django_routes(root, "core/backend/airshelf/urls.py", warnings),
|
|
"app_routes": app_routes,
|
|
"apps": apps,
|
|
"celery_tasks": tasks,
|
|
"configured_apps": configured_apps,
|
|
"deployment_files": deployment_files + k8s_files,
|
|
"models": models,
|
|
"providers": providers,
|
|
"requirements": requirement_names(root, warnings),
|
|
"settings_symbols": settings,
|
|
}
|
|
|
|
|
|
def relative_source_files(root: Path, base: str, suffixes: tuple[str, ...]) -> list[str]:
|
|
directory = safe_candidate(root, base)
|
|
if directory is None or not directory.is_dir() or directory.is_symlink():
|
|
return []
|
|
files = []
|
|
for item in directory.rglob("*"):
|
|
if not item.is_file() or item.is_symlink() or item.suffix.lower() not in suffixes:
|
|
continue
|
|
relative = item.relative_to(root).as_posix()
|
|
if not is_forbidden(relative):
|
|
files.append(relative)
|
|
return sorted(files)
|
|
|
|
|
|
def typescript_exports(root: Path, relative: str, warnings: set[str]) -> list[str]:
|
|
text = read_text(root, relative, warnings)
|
|
return [] if text is None else sorted(set(TS_EXPORT_RE.findall(text)))
|
|
|
|
|
|
def quoted_paths(root: Path, relative: str, warnings: set[str]) -> list[str]:
|
|
text = read_text(root, relative, warnings)
|
|
return [] if text is None else sorted(set(QUOTED_PATH_RE.findall(text)))
|
|
|
|
|
|
def stage_order(root: Path, warnings: set[str]) -> list[str]:
|
|
text = read_text(root, "core/frontend/src/routes/stage-config.ts", warnings)
|
|
if text is None:
|
|
return []
|
|
match = re.search(r"\bstageOrder\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
|
|
if not match:
|
|
return []
|
|
return re.findall(r"[\"']([A-Za-z0-9_-]+)[\"']", match.group(1))
|
|
|
|
|
|
def frontend_dependencies(root: Path, warnings: set[str]) -> dict[str, list[str]]:
|
|
text = read_text(root, "core/frontend/package.json", warnings)
|
|
if text is None:
|
|
return {"dependencies": [], "devDependencies": []}
|
|
try:
|
|
package = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
warnings.add("json_parse_failed:core/frontend/package.json")
|
|
return {"dependencies": [], "devDependencies": []}
|
|
return {
|
|
group: sorted(str(name) for name in package.get(group, {}).keys())
|
|
for group in ("dependencies", "devDependencies")
|
|
}
|
|
|
|
|
|
def collect_frontend(root: Path, warnings: set[str]) -> dict[str, Any]:
|
|
deployment_candidates = (
|
|
"core/frontend/Dockerfile",
|
|
"core/frontend/nginx.conf",
|
|
"core/frontend/vite.config.ts",
|
|
)
|
|
deployment_files = [
|
|
path for path in deployment_candidates if (safe_candidate(root, path) or Path()).is_file()
|
|
]
|
|
return {
|
|
"api_endpoints": quoted_paths(root, "core/frontend/src/api.ts", warnings),
|
|
"api_exports": typescript_exports(root, "core/frontend/src/api.ts", warnings),
|
|
"component_files": relative_source_files(
|
|
root, "core/frontend/src/components", (".ts", ".tsx")
|
|
),
|
|
"dependencies": frontend_dependencies(root, warnings),
|
|
"deployment_files": deployment_files,
|
|
"route_files": relative_source_files(root, "core/frontend/src/routes", (".ts", ".tsx")),
|
|
"route_paths": quoted_paths(root, "core/frontend/src/routes/route-config.ts", warnings),
|
|
"stage_order": stage_order(root, warnings),
|
|
"type_exports": typescript_exports(root, "core/frontend/src/types.ts", warnings),
|
|
}
|
|
|
|
|
|
def changed_line_text(root: Path, base: str, path: str, warnings: set[str]) -> list[str]:
|
|
if not is_relevant(path):
|
|
return []
|
|
output = git(
|
|
root,
|
|
("diff", "--no-ext-diff", "--unified=0", base, "--", path),
|
|
required=False,
|
|
)
|
|
if not output:
|
|
current = read_text(root, path, warnings)
|
|
return [] if current is None else [f"+{line}" for line in current.splitlines()]
|
|
if len(output) > 2_000_000:
|
|
warnings.add(f"diff_too_large:{path}")
|
|
output = output[:2_000_000]
|
|
text = output.decode("utf-8", errors="replace")
|
|
return [
|
|
line
|
|
for line in text.splitlines()
|
|
if line.startswith(("+", "-")) and not line.startswith(("+++", "---"))
|
|
]
|
|
|
|
|
|
def text_at_commit(root: Path, commit: str, path: str, warnings: set[str]) -> str | None:
|
|
if not is_relevant(path):
|
|
return None
|
|
output = git(root, ("show", f"{commit}:{path}"), required=False)
|
|
if not output:
|
|
return None
|
|
if len(output) > MAX_TEXT_BYTES:
|
|
warnings.add(f"base_file_too_large:{path}")
|
|
return None
|
|
try:
|
|
return output.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
warnings.add(f"base_file_not_utf8:{path}")
|
|
return None
|
|
|
|
|
|
def change_index(entries: Iterable[dict[str, str]]) -> dict[str, set[str]]:
|
|
indexed: dict[str, set[str]] = {}
|
|
for entry in entries:
|
|
status = entry["status"]
|
|
code = "A" if status == "??" else status[:1]
|
|
path = entry["path"]
|
|
if path:
|
|
indexed.setdefault(path, set()).add(code)
|
|
old_path = entry.get("old_path")
|
|
if old_path:
|
|
indexed.setdefault(old_path, set()).add("D")
|
|
indexed[path].add("A")
|
|
return indexed
|
|
|
|
|
|
def dependency_names_from_package(text: str | None) -> set[str] | None:
|
|
if text is None:
|
|
return set()
|
|
try:
|
|
package = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
names: set[str] = set()
|
|
for group in ("dependencies", "devDependencies"):
|
|
values = package.get(group, {})
|
|
if isinstance(values, dict):
|
|
names.update(str(name) for name in values)
|
|
return names
|
|
|
|
|
|
def dependency_names_from_requirements(text: str | None) -> set[str]:
|
|
if text is None:
|
|
return set()
|
|
names = set()
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith(("#", "-")):
|
|
continue
|
|
match = re.match(r"([A-Za-z0-9_.-]+)", stripped)
|
|
if match:
|
|
names.add(match.group(1).lower())
|
|
return names
|
|
|
|
|
|
def dependency_set_changed(
|
|
root: Path, base: str, path: str, warnings: set[str]
|
|
) -> bool:
|
|
before = text_at_commit(root, base, path, warnings)
|
|
after = read_text(root, path, warnings)
|
|
if path.endswith("package.json"):
|
|
before_names = dependency_names_from_package(before)
|
|
after_names = dependency_names_from_package(after)
|
|
if before_names is None or after_names is None:
|
|
warnings.add(f"dependency_parse_failed:{path}")
|
|
return True
|
|
return before_names != after_names
|
|
return dependency_names_from_requirements(before) != dependency_names_from_requirements(after)
|
|
|
|
|
|
def stage_order_from_text(text: str | None) -> list[str]:
|
|
if text is None:
|
|
return []
|
|
match = re.search(r"\bstageOrder\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
|
|
if not match:
|
|
return []
|
|
return re.findall(r"[\"']([A-Za-z0-9_-]+)[\"']", match.group(1))
|
|
|
|
|
|
def stage_order_changed(root: Path, base: str, warnings: set[str]) -> bool:
|
|
path = "core/frontend/src/routes/stage-config.ts"
|
|
before = text_at_commit(root, base, path, warnings)
|
|
after = read_text(root, path, warnings)
|
|
return stage_order_from_text(before) != stage_order_from_text(after)
|
|
|
|
|
|
def frontend_page_section(path: str) -> str:
|
|
if "/admin/" in path:
|
|
return "8.7 平台超管后台"
|
|
stem = PurePosixPath(path).stem
|
|
sections = {
|
|
"products": "8.1 商品",
|
|
"product-detail": "8.1 商品",
|
|
"product-create": "8.1 商品",
|
|
"projects": "8.2 视频项目",
|
|
"pipeline": "8.3 生产管线",
|
|
"ai-tools": "8.4 图片创作",
|
|
"models": "8.4 图片创作",
|
|
"free-create": "8.5 自由视频创作",
|
|
"library": "8.6 资产库与垃圾桶",
|
|
"trash": "8.6 资产库与垃圾桶",
|
|
}
|
|
return sections.get(stem, "8. 页面模块")
|
|
|
|
|
|
def impact_spec(
|
|
path: str,
|
|
statuses: set[str],
|
|
changed_lines: Sequence[str],
|
|
*,
|
|
dependency_changed: bool = False,
|
|
stages_changed: bool = False,
|
|
) -> dict[str, str] | None:
|
|
changed = "\n".join(changed_lines)
|
|
added_or_deleted = bool(statuses & {"A", "D", "R", "C"})
|
|
|
|
if path.startswith("core/backend/") or path.startswith("k8s/core/"):
|
|
document = BACKEND_DOCUMENT
|
|
app_match = re.match(r"core/backend/apps/([^/]+)/([^/]+\.py)$", path)
|
|
app = app_match.group(1) if app_match else ""
|
|
filename = app_match.group(2) if app_match else ""
|
|
app_section = BACKEND_APP_SECTIONS.get(app, "2. 目录结构、4. 应用模块")
|
|
|
|
if path.startswith("core/backend/airshelf/settings/") and re.search(
|
|
r"(?i)(INSTALLED_APPS|MIDDLEWARE|DATABASE|DB_ENGINE|\bENGINE\b|CACHE|REDIS|CELERY|"
|
|
r"REST_FRAMEWORK|AUTH_|Authentication|Permission|STORAGES|TOS_|ARK_|"
|
|
r"YUNQI_|TOKENSSR_|MODEL_|AI_|apps\.)",
|
|
changed,
|
|
):
|
|
return {
|
|
"change_type": "backend.settings_boundary",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "后端框架、基础设施或鉴权配置边界发生变化",
|
|
"section": "3.1 Settings、3.3 鉴权",
|
|
}
|
|
if path == "core/backend/airshelf/urls.py" and (
|
|
added_or_deleted or re.search(r"\b(path|re_path|include)\s*\(", changed)
|
|
):
|
|
return {
|
|
"change_type": "backend.root_routes",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "后端一级路由入口发生变化",
|
|
"section": "3.2 路由",
|
|
}
|
|
if filename == "apps.py" and (
|
|
added_or_deleted or re.search(r"(?m)^[+-].*\bname\s*=", changed)
|
|
):
|
|
return {
|
|
"change_type": "backend.django_apps",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": f"Django 应用 `{app}` 的注册结构发生变化",
|
|
"section": "2. 目录结构、4. 应用模块",
|
|
}
|
|
if filename == "models.py" and (
|
|
added_or_deleted
|
|
or re.search(r"(?m)^[+-]\s*class\s+\w+\s*\([^\n]*(?:Model|AbstractUser)", changed)
|
|
):
|
|
return {
|
|
"change_type": "backend.model_classes",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": f"`{app}` 核心模型类型发生增删或继承变化",
|
|
"section": app_section,
|
|
}
|
|
if filename == "urls.py" and (
|
|
added_or_deleted or re.search(r"\b(path|re_path|include)\s*\(", changed)
|
|
):
|
|
return {
|
|
"change_type": "backend.app_routes",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": f"`{app}` 应用接口边界发生变化",
|
|
"section": app_section,
|
|
}
|
|
if filename == "tasks.py" and (
|
|
added_or_deleted or re.search(r"(shared_task|periodic_task|\.task\s*\()", changed)
|
|
):
|
|
return {
|
|
"change_type": "backend.celery_tasks",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": f"`{app}` 的 Celery 任务入口发生变化",
|
|
"section": "7.1 Celery",
|
|
}
|
|
if path.startswith("core/backend/apps/ai/providers/") and (
|
|
added_or_deleted
|
|
or re.search(r"(?m)^[+-]\s*class\s+", changed)
|
|
or (
|
|
PurePosixPath(path).name == "__init__.py"
|
|
and re.search(r"(?m)^[+-]\s*(?:from|import)\s+", changed)
|
|
)
|
|
):
|
|
return {
|
|
"change_type": "backend.ai_providers",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "AI Provider 类型或适配层结构发生变化",
|
|
"section": "6. AI Provider 架构",
|
|
}
|
|
if path == "core/backend/requirements.txt" and dependency_changed:
|
|
return {
|
|
"change_type": "backend.dependencies",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "后端依赖集合发生变化",
|
|
"section": "10. 部署",
|
|
}
|
|
if path in {"core/backend/Dockerfile", "core/backend/docker-entrypoint.sh"} or path.startswith(
|
|
"k8s/core/"
|
|
):
|
|
if changed_lines or added_or_deleted:
|
|
return {
|
|
"change_type": "backend.deployment",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "后端容器或部署拓扑发生变化",
|
|
"section": "10. 部署",
|
|
}
|
|
return None
|
|
|
|
if path.startswith("core/frontend/"):
|
|
document = FRONTEND_DOCUMENT
|
|
if path == "core/frontend/package.json" and dependency_changed:
|
|
return {
|
|
"change_type": "frontend.dependencies",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "前端依赖集合发生变化",
|
|
"section": "2. 技术栈",
|
|
}
|
|
if path == "core/frontend/src/routes/route-config.ts" and (
|
|
added_or_deleted
|
|
or re.search(r"(?m)^[+-].*(?:[\"'`]/|type\s+Page|mainNav|supplementNav|pathForPage)", changed)
|
|
):
|
|
return {
|
|
"change_type": "frontend.routing",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "前端页面路由或导航边界发生变化",
|
|
"section": "5. 路由架构",
|
|
}
|
|
if path == "core/frontend/src/routes/stage-config.ts" and stages_changed:
|
|
return {
|
|
"change_type": "frontend.pipeline_stages",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "五阶段顺序或阶段标识发生变化",
|
|
"section": "8.3 生产管线",
|
|
}
|
|
if path.startswith("core/frontend/src/routes/") and PurePosixPath(path).suffix in {
|
|
".ts",
|
|
".tsx",
|
|
} and added_or_deleted:
|
|
return {
|
|
"change_type": "frontend.pages",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "正式业务页面模块发生增删或移动",
|
|
"section": frontend_page_section(path),
|
|
}
|
|
if path == "core/frontend/src/App.tsx" and re.search(
|
|
r"(?i)(createContext|Context\.Provider|useReducer|redux|zustand|RouterProvider|BrowserRouter)",
|
|
changed,
|
|
):
|
|
return {
|
|
"change_type": "frontend.app_controller",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "应用总控制器或全局状态边界发生变化",
|
|
"section": "4.2 `App.tsx`",
|
|
}
|
|
if path == "core/frontend/src/api.ts" and re.search(
|
|
r"(?i)(VITE_API_BASE_URL|Authorization|localStorage|sessionStorage|fetch\s*\(|"
|
|
r"axios|(?:const|function)\s+request\b|export\s+const\s+\w*Api\b)",
|
|
changed,
|
|
):
|
|
return {
|
|
"change_type": "frontend.api_boundary",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "前端请求、鉴权或 API SDK 边界发生变化",
|
|
"section": "6. API 层、7. 登录、存储与权限",
|
|
}
|
|
if path.startswith("core/frontend/src/components/") and added_or_deleted and re.search(
|
|
r"(?i)(shell|layout|router|provider|store|context)", PurePosixPath(path).stem
|
|
):
|
|
return {
|
|
"change_type": "frontend.shared_infrastructure",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "前端共享基础组件发生增删或移动",
|
|
"section": "3. 目录结构、4. 应用入口",
|
|
}
|
|
if path in {
|
|
"core/frontend/Dockerfile",
|
|
"core/frontend/nginx.conf",
|
|
"core/frontend/vite.config.ts",
|
|
} and (changed_lines or added_or_deleted):
|
|
return {
|
|
"change_type": "frontend.deployment",
|
|
"classification": "architecture",
|
|
"document": document,
|
|
"reason": "前端构建、代理或容器部署边界发生变化",
|
|
"section": "11. 构建与部署",
|
|
}
|
|
return None
|
|
|
|
|
|
def baseline_impacts(document: str, baseline_files: Sequence[str]) -> list[dict[str, Any]]:
|
|
if document == BACKEND_DOCUMENT:
|
|
groups = (
|
|
(
|
|
"backend.baseline.apps",
|
|
"2. 目录结构、4. 应用模块",
|
|
"缺少同步标记,需要核对 Django 应用与核心模型基线",
|
|
lambda path: "/apps/" in path and path.endswith(("apps.py", "models.py", "urls.py")),
|
|
),
|
|
(
|
|
"backend.baseline.entry",
|
|
"3. Django 工程入口",
|
|
"缺少同步标记,需要核对 Settings 与一级路由基线",
|
|
lambda path: "/airshelf/settings/" in path or path.endswith("airshelf/urls.py"),
|
|
),
|
|
(
|
|
"backend.baseline.execution",
|
|
"6. AI Provider 架构、7. 任务执行模型",
|
|
"缺少同步标记,需要核对 Provider 与 Celery 任务基线",
|
|
lambda path: "/providers/" in path or path.endswith("tasks.py"),
|
|
),
|
|
(
|
|
"backend.baseline.deployment",
|
|
"10. 部署",
|
|
"缺少同步标记,需要核对依赖、容器与部署基线",
|
|
lambda path: path.endswith(("requirements.txt", "Dockerfile", "docker-entrypoint.sh"))
|
|
or path.startswith("k8s/core/"),
|
|
),
|
|
)
|
|
else:
|
|
groups = (
|
|
(
|
|
"frontend.baseline.stack",
|
|
"2. 技术栈",
|
|
"缺少同步标记,需要核对前端技术依赖基线",
|
|
lambda path: path.endswith("package.json"),
|
|
),
|
|
(
|
|
"frontend.baseline.structure",
|
|
"3. 目录结构、5. 路由架构、8. 页面模块",
|
|
"缺少同步标记,需要核对页面、路由与共享组件基线",
|
|
lambda path: "/src/routes/" in path or "/src/components/" in path,
|
|
),
|
|
(
|
|
"frontend.baseline.boundaries",
|
|
"4. 应用入口、6. API 层",
|
|
"缺少同步标记,需要核对应用入口与 API SDK 基线",
|
|
lambda path: path.endswith(("src/App.tsx", "src/api.ts", "src/types.ts")),
|
|
),
|
|
(
|
|
"frontend.baseline.deployment",
|
|
"11. 构建与部署",
|
|
"缺少同步标记,需要核对构建、代理与容器基线",
|
|
lambda path: path.endswith(("Dockerfile", "nginx.conf", "vite.config.ts")),
|
|
),
|
|
)
|
|
impacts = []
|
|
for change_type, section, reason, matches in groups:
|
|
evidence = sorted(path for path in baseline_files if matches(path))[:24]
|
|
if evidence:
|
|
impacts.append(
|
|
{
|
|
"change_type": change_type,
|
|
"classification": "baseline_review",
|
|
"document": document,
|
|
"evidence": evidence,
|
|
"reason": reason,
|
|
"section": section,
|
|
}
|
|
)
|
|
return impacts
|
|
|
|
|
|
def map_impacts(
|
|
root: Path,
|
|
head: str | None,
|
|
documents: Sequence[str],
|
|
usable_markers: dict[str, str],
|
|
baseline_files: Sequence[str],
|
|
staged: Sequence[dict[str, str]],
|
|
unstaged: Sequence[dict[str, str]],
|
|
untracked: Sequence[dict[str, str]],
|
|
warnings: set[str],
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
|
impacts: list[dict[str, Any]] = []
|
|
ordinary: dict[str, str] = {}
|
|
for document in documents:
|
|
base = usable_markers.get(document)
|
|
if base is None or head is None:
|
|
impacts.extend(baseline_impacts(document, baseline_files))
|
|
continue
|
|
committed = diff_entries(root, ("diff", f"{base}..{head}"))
|
|
indexed = change_index([*committed, *staged, *unstaged, *untracked])
|
|
if document == BACKEND_DOCUMENT:
|
|
domain_paths = sorted(
|
|
path
|
|
for path in indexed
|
|
if path.startswith("core/backend/") or path.startswith("k8s/core/")
|
|
)
|
|
else:
|
|
domain_paths = sorted(
|
|
path for path in indexed if path.startswith("core/frontend/")
|
|
)
|
|
for path in domain_paths:
|
|
lines = changed_line_text(root, base, path, warnings)
|
|
dependency_changed = path in {
|
|
"core/backend/requirements.txt",
|
|
"core/frontend/package.json",
|
|
} and dependency_set_changed(root, base, path, warnings)
|
|
stages_changed = path == "core/frontend/src/routes/stage-config.ts" and stage_order_changed(
|
|
root, base, warnings
|
|
)
|
|
spec = impact_spec(
|
|
path,
|
|
indexed[path],
|
|
lines,
|
|
dependency_changed=dependency_changed,
|
|
stages_changed=stages_changed,
|
|
)
|
|
if spec is None:
|
|
ordinary[path] = "未发现会改变架构文档事实的结构信号"
|
|
continue
|
|
impacts.append({**spec, "evidence": [path]})
|
|
|
|
buckets: dict[tuple[str, str, str, str, str], set[str]] = {}
|
|
for impact in impacts:
|
|
key = (
|
|
impact["document"],
|
|
impact["section"],
|
|
impact["change_type"],
|
|
impact["classification"],
|
|
impact["reason"],
|
|
)
|
|
buckets.setdefault(key, set()).update(impact["evidence"])
|
|
merged = [
|
|
{
|
|
"change_type": key[2],
|
|
"classification": key[3],
|
|
"document": key[0],
|
|
"evidence": sorted(evidence),
|
|
"reason": key[4],
|
|
"section": key[1],
|
|
}
|
|
for key, evidence in buckets.items()
|
|
]
|
|
merged.sort(key=lambda item: (item["document"], item["section"], item["change_type"]))
|
|
non_architecture = [
|
|
{"path": path, "reason": reason} for path, reason in sorted(ordinary.items())
|
|
]
|
|
return merged, non_architecture
|
|
|
|
|
|
def encode_guard(payload: dict[str, Any]) -> str:
|
|
raw = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode(
|
|
"utf-8"
|
|
)
|
|
encoded = base64.urlsafe_b64encode(zlib.compress(raw, level=9)).decode("ascii").rstrip("=")
|
|
checksum = hashlib.sha256(raw).hexdigest()[:16]
|
|
return f"{encoded}.{checksum}"
|
|
|
|
|
|
def decode_guard(token: str) -> dict[str, Any]:
|
|
try:
|
|
encoded, checksum = token.rsplit(".", 1)
|
|
padding = "=" * (-len(encoded) % 4)
|
|
raw = zlib.decompress(base64.urlsafe_b64decode(encoded + padding))
|
|
if hashlib.sha256(raw).hexdigest()[:16] != checksum:
|
|
raise ValueError("checksum")
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, zlib.error) as error:
|
|
raise ScanError("verification_guard_invalid") from error
|
|
if not isinstance(payload, dict) or payload.get("guard_version") != 1:
|
|
raise ScanError("verification_guard_invalid")
|
|
return payload
|
|
|
|
|
|
def headings_from_text(text: str, maximum_level: int = 3) -> list[dict[str, Any]]:
|
|
return [
|
|
{"level": len(match.group(1)), "title": match.group(2).strip()}
|
|
for match in HEADING_RE.finditer(text)
|
|
if len(match.group(1)) <= maximum_level
|
|
]
|
|
|
|
|
|
def section_records(text: str) -> list[dict[str, Any]]:
|
|
matches = [
|
|
match for match in HEADING_RE.finditer(text) if len(match.group(1)) <= 3
|
|
]
|
|
records: list[dict[str, Any]] = []
|
|
stack: list[tuple[int, str]] = []
|
|
key_counts: dict[str, int] = {}
|
|
for index, match in enumerate(matches):
|
|
level = len(match.group(1))
|
|
title = match.group(2).strip()
|
|
while stack and stack[-1][0] >= level:
|
|
stack.pop()
|
|
ancestors = [item[1] for item in stack]
|
|
stack.append((level, title))
|
|
end = len(text)
|
|
for candidate in matches[index + 1 :]:
|
|
if len(candidate.group(1)) <= level:
|
|
end = candidate.start()
|
|
break
|
|
base_key = f"{level}:{title}"
|
|
key_counts[base_key] = key_counts.get(base_key, 0) + 1
|
|
key = (
|
|
base_key
|
|
if key_counts[base_key] == 1
|
|
else f"{base_key}#{key_counts[base_key]}"
|
|
)
|
|
records.append(
|
|
{
|
|
"ancestors": ancestors,
|
|
"end": end,
|
|
"hash": hashlib.sha256(text[match.start() : end].encode("utf-8")).hexdigest(),
|
|
"key": key,
|
|
"level": level,
|
|
"start": match.start(),
|
|
"title": title,
|
|
}
|
|
)
|
|
return records
|
|
|
|
|
|
def protected_section_hashes(text: str, planned_sections: Sequence[str]) -> dict[str, str]:
|
|
records = section_records(text)
|
|
planned = set(planned_sections)
|
|
protected: dict[str, str] = {}
|
|
for record in records:
|
|
descendant_planned = any(
|
|
other["title"] in planned
|
|
and other["start"] > record["start"]
|
|
and other["start"] < record["end"]
|
|
for other in records
|
|
)
|
|
affected = (
|
|
record["title"] in planned
|
|
or any(ancestor in planned for ancestor in record["ancestors"])
|
|
or descendant_planned
|
|
)
|
|
if not affected:
|
|
protected[record["key"]] = record["hash"]
|
|
return protected
|
|
|
|
|
|
def document_header_hash(text: str) -> str:
|
|
second_level = re.search(r"(?m)^##\s+", text)
|
|
header = text[: second_level.start()] if second_level else text
|
|
kept_lines = []
|
|
for line in header.splitlines():
|
|
stripped = line.strip()
|
|
if re.fullmatch(r">\s*最后核对:\d{4}-\d{2}-\d{2}。", stripped):
|
|
continue
|
|
if SYNC_MARKER_RE.fullmatch(stripped):
|
|
continue
|
|
kept_lines.append(line.rstrip())
|
|
normalized = "\n".join(kept_lines).strip()
|
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def is_ordered_subsequence(before: Sequence[str], current: Sequence[str]) -> bool:
|
|
iterator = iter(current)
|
|
return all(any(candidate == expected for candidate in iterator) for expected in before)
|
|
|
|
|
|
def validate_headings(
|
|
document: str,
|
|
before: Sequence[dict[str, Any]],
|
|
current: Sequence[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
errors: list[dict[str, Any]] = []
|
|
before_primary = [item["title"] for item in before if item["level"] <= 2]
|
|
current_primary = [item["title"] for item in current if item["level"] <= 2]
|
|
if before_primary != current_primary:
|
|
errors.append(
|
|
verification_error(
|
|
"primary_headings_changed",
|
|
"原有一级或二级标题被删除、改名、增加或调整顺序。",
|
|
"恢复原有一级和二级标题;内容变化应限制在已批准章节正文内。",
|
|
document=document,
|
|
)
|
|
)
|
|
before_tertiary = [item["title"] for item in before if item["level"] == 3]
|
|
current_tertiary = [item["title"] for item in current if item["level"] == 3]
|
|
if not is_ordered_subsequence(before_tertiary, current_tertiary):
|
|
errors.append(
|
|
verification_error(
|
|
"existing_subheadings_removed_or_reordered",
|
|
"原有三级标题被删除或调整顺序。",
|
|
"恢复原有三级标题及顺序;如需新增标题,只能追加到已批准章节。",
|
|
document=document,
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def markdown_targets(text: str) -> list[str]:
|
|
values = MARKDOWN_LINK_RE.findall(text) + MARKDOWN_REFERENCE_LINK_RE.findall(text)
|
|
targets = []
|
|
for value in values:
|
|
target = value.strip().strip("<>")
|
|
if " " in target and not target.startswith(("http://", "https://")):
|
|
target = target.split(" ", 1)[0]
|
|
targets.append(target)
|
|
return targets
|
|
|
|
|
|
def validate_relative_links(root: Path, document: str, text: str) -> list[dict[str, Any]]:
|
|
errors: list[dict[str, Any]] = []
|
|
document_path = root.joinpath(*PurePosixPath(document).parts)
|
|
for raw_target in markdown_targets(text):
|
|
if not raw_target or raw_target.startswith("#"):
|
|
continue
|
|
if raw_target.lower().startswith("file://"):
|
|
errors.append(
|
|
verification_error(
|
|
"absolute_link_forbidden",
|
|
"文档包含本机绝对链接。",
|
|
"改为仓库内相对链接。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
target = unquote(raw_target.split("#", 1)[0].split("?", 1)[0])
|
|
if not target:
|
|
continue
|
|
if target.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:[\\/]", target):
|
|
errors.append(
|
|
verification_error(
|
|
"absolute_link_forbidden",
|
|
"文档包含本机或根路径链接。",
|
|
"改为仓库内相对链接。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", raw_target):
|
|
continue
|
|
candidate = (document_path.parent / target).resolve(strict=False)
|
|
try:
|
|
relative = candidate.relative_to(root.resolve(strict=True)).as_posix()
|
|
except (OSError, ValueError):
|
|
errors.append(
|
|
verification_error(
|
|
"link_outside_repository",
|
|
"相对链接解析到了仓库外部。",
|
|
"将链接改为仓库内部存在的相对路径。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
if is_forbidden(relative) or not candidate.exists():
|
|
errors.append(
|
|
verification_error(
|
|
"relative_link_invalid",
|
|
"文档包含不存在或不允许引用的相对链接。",
|
|
"修正链接目标,或移除无法由仓库内容证明的链接。",
|
|
document=document,
|
|
target=raw_target,
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def validate_document_safety(document: str, text: str) -> list[dict[str, Any]]:
|
|
errors: list[dict[str, Any]] = []
|
|
if ABSOLUTE_LOCAL_PATH_RE.search(text):
|
|
errors.append(
|
|
verification_error(
|
|
"absolute_local_path_detected",
|
|
"文档包含本机绝对路径。",
|
|
"删除本机路径并改用仓库相对路径。",
|
|
document=document,
|
|
)
|
|
)
|
|
if any(pattern.search(text) for pattern in SECRET_CONTENT_PATTERNS):
|
|
errors.append(
|
|
verification_error(
|
|
"sensitive_content_detected",
|
|
"文档包含疑似密钥、密码或私钥内容。",
|
|
"立即删除敏感值,仅保留环境变量名称或安全配置说明。",
|
|
document=document,
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def referenced_code_paths(document: str, text: str) -> list[str]:
|
|
candidates = set()
|
|
for value in re.findall(r"`([^`\n]+)`", text):
|
|
value = value.strip().replace("\\", "/")
|
|
if not value or any(char in value for char in "*?{}<>") or " " in value:
|
|
continue
|
|
if value.startswith("core/"):
|
|
candidate = value.rstrip("/.,;:")
|
|
elif document == BACKEND_DOCUMENT and value.startswith(("apps/", "airshelf/", "skills/")):
|
|
candidate = f"core/backend/{value}".rstrip("/.,;:")
|
|
elif document == FRONTEND_DOCUMENT and value.startswith(("src/", "public/")):
|
|
candidate = f"core/frontend/{value}".rstrip("/.,;:")
|
|
else:
|
|
continue
|
|
candidates.add(normalize_path(candidate))
|
|
return sorted(path for path in candidates if path)
|
|
|
|
|
|
def validate_code_references(
|
|
root: Path, document: str, text: str
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
errors: list[dict[str, Any]] = []
|
|
checked: list[str] = []
|
|
for path in referenced_code_paths(document, text):
|
|
if is_forbidden(path):
|
|
errors.append(
|
|
verification_error(
|
|
"forbidden_code_reference",
|
|
"文档引用了敏感或排除路径。",
|
|
"移除该引用,仅保留允许公开的架构事实。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
candidate = safe_candidate(root, path)
|
|
if candidate is None or not candidate.exists():
|
|
errors.append(
|
|
verification_error(
|
|
"code_reference_missing",
|
|
"文档中的代码路径无法在仓库中验证。",
|
|
"修正路径,或删除没有代码依据的描述。",
|
|
document=document,
|
|
path=path,
|
|
)
|
|
)
|
|
continue
|
|
checked.append(path)
|
|
return errors, checked
|
|
|
|
|
|
def expand_sections(section: str) -> list[str]:
|
|
return [part.strip() for part in section.split("、") if part.strip()]
|
|
|
|
|
|
def planned_updates(impacts: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
sections_by_document: dict[str, set[str]] = {}
|
|
for impact in impacts:
|
|
sections_by_document.setdefault(impact["document"], set()).update(
|
|
expand_sections(impact["section"])
|
|
)
|
|
return [
|
|
{"document": document, "sections": sorted(sections)}
|
|
for document, sections in sorted(sections_by_document.items())
|
|
]
|
|
|
|
|
|
def build_snapshot(root: Path, mode: str, scope: str = "all") -> dict[str, Any]:
|
|
warnings: set[str] = set()
|
|
targets = selected_documents(scope)
|
|
head = current_head(root)
|
|
if head is None:
|
|
warnings.add("head_commit_unavailable")
|
|
|
|
documents, markers = document_states(root, warnings, targets)
|
|
usable_markers: dict[str, str] = {}
|
|
for document, marker in markers.items():
|
|
if marker is None:
|
|
warnings.add(f"sync_marker_missing:{document}")
|
|
elif marker_is_usable(root, marker, head):
|
|
usable_markers[document] = marker
|
|
else:
|
|
warnings.add(f"sync_marker_unusable:{document}")
|
|
|
|
baseline_required = len(usable_markers) != len(targets)
|
|
committed = []
|
|
for marker in sorted(set(usable_markers.values())):
|
|
committed.append(
|
|
{
|
|
"base_commit": marker,
|
|
"files": filter_entries(
|
|
diff_entries(root, ("diff", f"{marker}..{head}")), scope
|
|
),
|
|
}
|
|
)
|
|
baseline_files = (
|
|
[path for path in walk_relevant_files(root) if path_in_scope(path, scope)]
|
|
if baseline_required
|
|
else []
|
|
)
|
|
staged = filter_entries(diff_entries(root, ("diff", "--cached")), scope)
|
|
unstaged = filter_entries(diff_entries(root, ("diff",)), scope)
|
|
untracked = filter_entries(untracked_entries(root), scope)
|
|
|
|
changed_paths = set(baseline_files)
|
|
for group in committed:
|
|
changed_paths.update(item["path"] for item in group["files"])
|
|
for group in (staged, unstaged, untracked):
|
|
changed_paths.update(item["path"] for item in group)
|
|
|
|
common_marker: str | None = None
|
|
marker_values = set(usable_markers.values())
|
|
if len(usable_markers) == len(targets) and len(marker_values) == 1:
|
|
common_marker = next(iter(marker_values))
|
|
|
|
impacts, non_architecture = map_impacts(
|
|
root,
|
|
head,
|
|
targets,
|
|
usable_markers,
|
|
baseline_files,
|
|
staged,
|
|
unstaged,
|
|
untracked,
|
|
warnings,
|
|
)
|
|
architecture: dict[str, Any] = {}
|
|
if BACKEND_DOCUMENT in targets:
|
|
architecture["backend"] = collect_backend(root, warnings)
|
|
if FRONTEND_DOCUMENT in targets:
|
|
architecture["frontend"] = collect_frontend(root, warnings)
|
|
content_fingerprint, fingerprinted_file_count = scope_content_fingerprint(
|
|
root, scope, warnings
|
|
)
|
|
snapshot_core = {
|
|
"architecture": architecture,
|
|
"changed_files": sorted(changed_paths),
|
|
"changes": {
|
|
"baseline": baseline_files,
|
|
"committed": committed,
|
|
"staged": staged,
|
|
"unstaged": unstaged,
|
|
"untracked": untracked,
|
|
},
|
|
"content_fingerprint": content_fingerprint,
|
|
"documents": documents,
|
|
"fingerprinted_file_count": fingerprinted_file_count,
|
|
"repository": {
|
|
"base_commit": common_marker,
|
|
"baseline_documents": sorted(set(targets) - set(usable_markers)),
|
|
"baseline_required": baseline_required,
|
|
"current_commit": head,
|
|
"sync_commits": markers,
|
|
},
|
|
"scope": scope,
|
|
}
|
|
canonical = json.dumps(snapshot_core, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
snapshot_id = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20]
|
|
warnings_list = sorted(warnings)
|
|
update_plan = planned_updates(impacts)
|
|
requires_update = bool(impacts)
|
|
plan_material = {
|
|
"impacts": impacts,
|
|
"non_architecture_changes": non_architecture,
|
|
"planned_updates": update_plan,
|
|
"scope": scope,
|
|
"snapshot_id": snapshot_id,
|
|
"warnings": warnings_list,
|
|
}
|
|
plan_canonical = json.dumps(
|
|
plan_material, ensure_ascii=True, sort_keys=True, separators=(",", ":")
|
|
)
|
|
plan_id = f"arch-{hashlib.sha256(plan_canonical.encode('utf-8')).hexdigest()[:20]}"
|
|
evidence_allowlist = sorted(
|
|
{path for impact in impacts for path in impact.get("evidence", [])}
|
|
)
|
|
document_preconditions = [
|
|
{
|
|
"document": item["document"],
|
|
"before_sha256": documents[item["document"]]["sha256"],
|
|
"sections": item["sections"],
|
|
}
|
|
for item in update_plan
|
|
]
|
|
working_tree_evidence = sorted(
|
|
{
|
|
entry["path"]
|
|
for entries in (staged, unstaged, untracked)
|
|
for entry in entries
|
|
}
|
|
)
|
|
control_paths = (
|
|
"tools/architecture-sync/WORKFLOW.md",
|
|
"tools/architecture-sync/scan_architecture.py",
|
|
".agents/skills/sync-architecture-docs/SKILL.md",
|
|
".claude/skills/sync-architecture-docs/SKILL.md",
|
|
)
|
|
guard_documents: dict[str, Any] = {}
|
|
for item in document_preconditions:
|
|
document_text = read_text(root, item["document"], warnings) or ""
|
|
guard_documents[item["document"]] = {
|
|
"before_sha256": item["before_sha256"],
|
|
"header_hash": document_header_hash(document_text),
|
|
"headings": [
|
|
heading
|
|
for heading in documents[item["document"]]["headings"]
|
|
if heading["level"] <= 3
|
|
],
|
|
"protected_sections": protected_section_hashes(
|
|
document_text, item["sections"]
|
|
),
|
|
"sections": item["sections"],
|
|
}
|
|
guard_payload = {
|
|
"allowed_documents": [item["document"] for item in update_plan],
|
|
"content_fingerprint": content_fingerprint,
|
|
"current_commit": head,
|
|
"documents": guard_documents,
|
|
"evidence_sample": [
|
|
{
|
|
"exists": bool(
|
|
(safe_candidate(root, path) or Path("__missing__")).exists()
|
|
),
|
|
"path": path,
|
|
}
|
|
for path in evidence_allowlist[:24]
|
|
],
|
|
"guard_version": 1,
|
|
"initial_worktree": worktree_guard(root),
|
|
"plan_id": plan_id,
|
|
"protected_control_files": {
|
|
path: file_fingerprint(root, path, warnings) for path in control_paths
|
|
},
|
|
"scope": scope,
|
|
}
|
|
verification_guard = encode_guard(guard_payload)
|
|
return {
|
|
"analysis_ready": True,
|
|
**snapshot_core,
|
|
"confirmation": {
|
|
"accepted_phrase": "确认更新项目文档",
|
|
"plan_id": plan_id,
|
|
"required": requires_update,
|
|
"scanner_write_capability": False,
|
|
"short_phrase": "确认",
|
|
"short_phrase_requires_same_pending_context": True,
|
|
},
|
|
"impact_mapping_version": 1,
|
|
"impacts": impacts,
|
|
"mode": mode,
|
|
"next_action": "await_confirmation" if requires_update else "none",
|
|
"non_architecture_changes": non_architecture,
|
|
"plan_id": plan_id,
|
|
"planned_updates": update_plan,
|
|
"requires_update": requires_update,
|
|
"schema_version": SCHEMA_VERSION,
|
|
"snapshot_id": snapshot_id,
|
|
"update_context": {
|
|
"document_preconditions": document_preconditions,
|
|
"evidence_allowlist": evidence_allowlist,
|
|
"git_actions_allowed": False,
|
|
"patch_method": "apply_patch",
|
|
"plan_id": plan_id,
|
|
"read_policy": "minimum_needed_from_allowlist",
|
|
"sync_metadata": {
|
|
"checked_date_format": "YYYY-MM-DD",
|
|
"commit": head,
|
|
"has_uncommitted_evidence": bool(working_tree_evidence),
|
|
"marker": (
|
|
f"<!-- architecture-sync-commit: {head} -->" if head else None
|
|
),
|
|
},
|
|
"verification_guard": verification_guard,
|
|
"working_tree_evidence": working_tree_evidence,
|
|
},
|
|
"warnings": warnings_list,
|
|
"write_policy": {
|
|
"current_plan_documents": list(targets),
|
|
"never_modify": [
|
|
"core/ARCHITECTURE.md",
|
|
"business_code",
|
|
"configuration",
|
|
"migrations",
|
|
"tests",
|
|
"deployment_files",
|
|
"git_index",
|
|
"git_commits",
|
|
"git_remote",
|
|
],
|
|
"write_authorized": False,
|
|
},
|
|
}
|
|
|
|
|
|
def verify_snapshot(root: Path, token: str, scope: str) -> dict[str, Any]:
|
|
errors: list[dict[str, Any]] = []
|
|
warnings: list[str] = []
|
|
checked_code_paths: set[str] = set()
|
|
guard = decode_guard(token)
|
|
plan_id = str(guard.get("plan_id", ""))
|
|
guard_scope = guard.get("scope")
|
|
if guard_scope != scope:
|
|
errors.append(
|
|
verification_error(
|
|
"scope_mismatch",
|
|
"校验范围与已确认计划不一致。",
|
|
"使用原计划相同的 --scope 重新执行 verify。",
|
|
)
|
|
)
|
|
allowed_documents = set(guard.get("allowed_documents", []))
|
|
if not allowed_documents or not allowed_documents.issubset(set(TARGET_DOCUMENTS)):
|
|
errors.append(
|
|
verification_error(
|
|
"allowed_documents_invalid",
|
|
"校验令牌没有有效的目标文档白名单。",
|
|
"丢弃当前令牌并重新运行 plan。",
|
|
)
|
|
)
|
|
|
|
head = current_head(root)
|
|
if head != guard.get("current_commit"):
|
|
errors.append(
|
|
verification_error(
|
|
"head_commit_changed",
|
|
"计划生成后 HEAD 已发生变化。",
|
|
"停止写入并重新运行 plan 获取新确认。",
|
|
)
|
|
)
|
|
|
|
fingerprint_warnings: set[str] = set()
|
|
current_fingerprint, fingerprinted_count = scope_content_fingerprint(
|
|
root, str(guard_scope), fingerprint_warnings
|
|
)
|
|
if current_fingerprint != guard.get("content_fingerprint"):
|
|
errors.append(
|
|
verification_error(
|
|
"code_state_changed",
|
|
"计划生成后相关代码事实发生变化。",
|
|
"停止当前更新,重新扫描并重新确认。",
|
|
)
|
|
)
|
|
warnings.extend(sorted(fingerprint_warnings))
|
|
|
|
for path, expected in guard.get("protected_control_files", {}).items():
|
|
current = file_fingerprint(root, path, set())
|
|
if current != expected:
|
|
errors.append(
|
|
verification_error(
|
|
"control_file_changed",
|
|
"同步工作流、扫描器或 Skill 在执行期间发生变化。",
|
|
"停止验证,恢复控制文件或重新生成计划。",
|
|
path=path,
|
|
)
|
|
)
|
|
|
|
errors.extend(
|
|
compare_worktree_guards(
|
|
guard.get("initial_worktree", {}), worktree_guard(root), allowed_documents
|
|
)
|
|
)
|
|
|
|
document_results = []
|
|
for document in sorted(allowed_documents):
|
|
guard_document = guard.get("documents", {}).get(document)
|
|
if not isinstance(guard_document, dict):
|
|
errors.append(
|
|
verification_error(
|
|
"document_guard_missing",
|
|
"目标文档缺少修改前保护信息。",
|
|
"丢弃当前令牌并重新运行 plan。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
read_warnings: set[str] = set()
|
|
data = read_bytes(root, document, read_warnings)
|
|
if data is None:
|
|
errors.append(
|
|
verification_error(
|
|
"target_document_missing",
|
|
"目标架构文档不存在或无法读取。",
|
|
"恢复目标文档后重新生成计划。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
warnings.extend(sorted(read_warnings))
|
|
try:
|
|
text = data.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
errors.append(
|
|
verification_error(
|
|
"target_document_not_utf8",
|
|
"目标架构文档不是有效 UTF-8。",
|
|
"将文档恢复为 UTF-8 编码后重新验证。",
|
|
document=document,
|
|
)
|
|
)
|
|
continue
|
|
current_sha = hashlib.sha256(data).hexdigest()
|
|
changed = current_sha != guard_document.get("before_sha256")
|
|
if not changed:
|
|
warnings.append(f"planned_document_unchanged:{document}")
|
|
current_headings = headings_from_text(text)
|
|
errors.extend(
|
|
validate_headings(document, guard_document.get("headings", []), current_headings)
|
|
)
|
|
if document_header_hash(text) != guard_document.get("header_hash"):
|
|
errors.append(
|
|
verification_error(
|
|
"document_header_changed",
|
|
"文档头部除核对日期和同步标记外发生了计划外变化。",
|
|
"恢复文档标题和说明,只保留允许更新的日期与同步标记。",
|
|
document=document,
|
|
)
|
|
)
|
|
current_sections = {record["key"]: record["hash"] for record in section_records(text)}
|
|
changed_protected_sections = [
|
|
key
|
|
for key, expected_hash in guard_document.get("protected_sections", {}).items()
|
|
if current_sections.get(key) != expected_hash
|
|
]
|
|
if changed_protected_sections:
|
|
errors.append(
|
|
verification_error(
|
|
"unplanned_section_changed",
|
|
"计划外章节正文发生变化。",
|
|
"恢复计划外章节,只保留 planned_updates 指定范围内的局部补丁。",
|
|
document=document,
|
|
sections=changed_protected_sections,
|
|
)
|
|
)
|
|
errors.extend(validate_relative_links(root, document, text))
|
|
errors.extend(validate_document_safety(document, text))
|
|
reference_errors, checked = validate_code_references(root, document, text)
|
|
errors.extend(reference_errors)
|
|
checked_code_paths.update(checked)
|
|
|
|
if changed:
|
|
markers = [value.lower() for value in SYNC_MARKER_RE.findall(text)]
|
|
if len(markers) != 1 or markers[0] != guard.get("current_commit"):
|
|
errors.append(
|
|
verification_error(
|
|
"sync_marker_invalid",
|
|
"已修改文档缺少唯一且正确的同步 commit 标记。",
|
|
"在文首保留一条指向计划 HEAD 的 architecture-sync-commit 标记。",
|
|
document=document,
|
|
)
|
|
)
|
|
if not re.search(r"(?m)^>\s*最后核对:\d{4}-\d{2}-\d{2}。\s*$", text):
|
|
errors.append(
|
|
verification_error(
|
|
"checked_date_missing",
|
|
"已修改文档缺少格式正确的最后核对日期。",
|
|
"在文首使用“> 最后核对:YYYY-MM-DD。”格式更新日期。",
|
|
document=document,
|
|
)
|
|
)
|
|
document_results.append(
|
|
{
|
|
"changed": changed,
|
|
"document": document,
|
|
"headings_checked": len(current_headings),
|
|
}
|
|
)
|
|
|
|
evidence_checked = []
|
|
for item in guard.get("evidence_sample", []):
|
|
path = item.get("path", "")
|
|
if not path or is_forbidden(path):
|
|
errors.append(
|
|
verification_error(
|
|
"evidence_path_invalid",
|
|
"校验令牌包含越界或敏感证据路径。",
|
|
"丢弃当前令牌并重新运行 plan。",
|
|
)
|
|
)
|
|
continue
|
|
current_exists = bool((safe_candidate(root, path) or Path("__missing__")).exists())
|
|
if current_exists != bool(item.get("exists")):
|
|
errors.append(
|
|
verification_error(
|
|
"evidence_presence_changed",
|
|
"计划证据文件的存在状态发生变化。",
|
|
"停止当前更新,重新扫描并重新确认。",
|
|
path=path,
|
|
)
|
|
)
|
|
elif current_exists:
|
|
evidence_checked.append(path)
|
|
|
|
status = "passed" if not errors else "failed"
|
|
return {
|
|
"mode": "verify",
|
|
"plan_id": plan_id,
|
|
"schema_version": SCHEMA_VERSION,
|
|
"verification": {
|
|
"checks": {
|
|
"allowed_documents": sorted(allowed_documents),
|
|
"code_references_checked": len(checked_code_paths),
|
|
"evidence_files_checked": len(evidence_checked),
|
|
"fingerprinted_files_checked": fingerprinted_count,
|
|
"head_commit": head,
|
|
"scope": guard_scope,
|
|
},
|
|
"documents": document_results,
|
|
"errors": errors,
|
|
"status": status,
|
|
"warnings": sorted(set(warnings)),
|
|
},
|
|
}
|
|
|
|
|
|
def parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Read-only AirShelf architecture scanner")
|
|
parser.add_argument("mode", choices=("plan", "verify"))
|
|
parser.add_argument(
|
|
"--scope",
|
|
choices=("all", "backend", "frontend"),
|
|
default="all",
|
|
help="Limit the current plan to both documents or one architecture domain",
|
|
)
|
|
parser.add_argument(
|
|
"--guard",
|
|
help="Verification guard emitted by plan mode; required by verify mode",
|
|
)
|
|
parser.add_argument(
|
|
"--repo-root",
|
|
type=Path,
|
|
default=Path.cwd(),
|
|
help="Any path inside the Git repository (default: current directory)",
|
|
)
|
|
return parser.parse_args(arguments)
|
|
|
|
|
|
def main(arguments: Sequence[str] | None = None) -> int:
|
|
args = parse_arguments(arguments)
|
|
try:
|
|
root = locate_repository(args.repo_root)
|
|
if args.mode == "verify":
|
|
if not args.guard:
|
|
payload = {
|
|
"mode": "verify",
|
|
"schema_version": SCHEMA_VERSION,
|
|
"verification": {
|
|
"errors": [
|
|
verification_error(
|
|
"verification_guard_required",
|
|
"verify 模式缺少 plan 生成的校验令牌。",
|
|
"先运行 plan,确认后传入 update_context.verification_guard。",
|
|
)
|
|
],
|
|
"status": "failed",
|
|
"warnings": [],
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=True, sort_keys=True, indent=2))
|
|
return 3
|
|
payload = verify_snapshot(root, args.guard, args.scope)
|
|
else:
|
|
payload = build_snapshot(root, args.mode, args.scope)
|
|
except ScanError as error:
|
|
if args.mode == "verify":
|
|
payload = {
|
|
"mode": "verify",
|
|
"schema_version": SCHEMA_VERSION,
|
|
"verification": {
|
|
"errors": [
|
|
verification_error(
|
|
str(error),
|
|
"校验令牌无效或只读校验无法继续。",
|
|
"丢弃当前令牌,重新运行 plan 并重新确认。",
|
|
)
|
|
],
|
|
"status": "failed",
|
|
"warnings": [],
|
|
},
|
|
}
|
|
else:
|
|
payload = {
|
|
"error": str(error),
|
|
"mode": args.mode,
|
|
"schema_version": SCHEMA_VERSION,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=True, sort_keys=True, indent=2))
|
|
return 2
|
|
print(json.dumps(payload, ensure_ascii=True, sort_keys=True, indent=2))
|
|
if args.mode == "verify" and payload["verification"]["status"] != "passed":
|
|
return 3
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
raise SystemExit(main())
|