migration: establish exact preserved app baseline
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "config" / "api.config.json"
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _owner(path: str) -> str:
|
||||
ordered = (
|
||||
("/api/health", "health"),
|
||||
("/api/auth", "auth"),
|
||||
("/api/admin", "admin"),
|
||||
("/api/account", "account"),
|
||||
("/api/sentiment", "sentiment"),
|
||||
("/api/dashboard", "market"),
|
||||
("/api/realtime-aggregate", "market"),
|
||||
("/api/rotation", "rotation"),
|
||||
("/api/auction", "auction"),
|
||||
("/api/themes", "themes"),
|
||||
("/api/popularity", "popularity"),
|
||||
("/api/dragon-tiger", "dragon_tiger"),
|
||||
("/api/search", "search"),
|
||||
("/api/chart", "charts"),
|
||||
("/api/stock", "market"),
|
||||
("/api/screener", "screener"),
|
||||
("/api/mentors", "mentor"),
|
||||
("/api/heaven", "heaven"),
|
||||
("/api/alerts", "alerts"),
|
||||
("/api/trades", "review"),
|
||||
("/api/assistant", "review"),
|
||||
("/api/watchlist", "review"),
|
||||
("/api/notes", "review"),
|
||||
("/api/reasons", "admin"),
|
||||
("/api/seat-aliases", "admin"),
|
||||
("/api/backfill", "admin"),
|
||||
)
|
||||
for prefix, owner in ordered:
|
||||
if path.startswith(prefix):
|
||||
return owner
|
||||
raise ValueError(f"API owner is not registered: {path}")
|
||||
|
||||
|
||||
def _role(method: str, path: str) -> str:
|
||||
if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}:
|
||||
return "public"
|
||||
from api_access import required_role
|
||||
|
||||
sample = re.sub(r"\\d\{6\}", "000001", path)
|
||||
sample = re.sub(r"\\d\+", "1", sample)
|
||||
sample = sample.replace("(.+)", "sample").replace("(", "").replace(")", "")
|
||||
return required_role(method, sample)
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
text = (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
|
||||
routes = []
|
||||
for index, match in enumerate(method_matches):
|
||||
method = match.group(1)
|
||||
end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text)
|
||||
block = text[match.start():end]
|
||||
exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block))
|
||||
patterns = set(
|
||||
re.findall(
|
||||
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
|
||||
block,
|
||||
)
|
||||
)
|
||||
for path in sorted(exact_paths):
|
||||
routes.append(
|
||||
{"method": method, "path": path, "match": "exact", "feature": _owner(path), "access": _role(method, path)}
|
||||
)
|
||||
for path in sorted(patterns):
|
||||
normalized = path.replace("^", "").replace("$", "")
|
||||
routes.append(
|
||||
{"method": method, "path": normalized, "match": "regex", "feature": _owner(normalized), "access": _role(method, normalized)}
|
||||
)
|
||||
routes.sort(key=lambda item: (item["path"], item["method"], item["match"]))
|
||||
return {"schema_version": 1, "generated_from": "server.py", "routes": routes}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build the transitional API ownership registry")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
|
||||
raise SystemExit("API registry is stale; run tools/build_api_registry.py")
|
||||
print("API registry is current.")
|
||||
return 0
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
print(OUTPUT.relative_to(ROOT).as_posix())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "docs" / "governance" / "architecture-inventory.json"
|
||||
|
||||
|
||||
def relative(path: Path) -> str:
|
||||
return path.relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def source(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def page_inventory(html: str) -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
pattern = re.compile(
|
||||
r'<button[^>]+data-view="(?P<id>[^"]+)"[^>]+title="(?P<title>[^"]+)"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for match in pattern.finditer(html):
|
||||
page_id = match.group("id")
|
||||
if page_id in seen:
|
||||
continue
|
||||
seen.add(page_id)
|
||||
items.append({"id": page_id, "title": match.group("title")})
|
||||
return items
|
||||
|
||||
|
||||
def api_inventory(server: str) -> dict[str, list[str]]:
|
||||
exact = sorted(set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', server)))
|
||||
prefixes = sorted(
|
||||
set(re.findall(r'parsed\.path\.startswith\(\s*"(/api/[^"]+)"', server))
|
||||
)
|
||||
patterns = sorted(
|
||||
set(
|
||||
item
|
||||
for item in re.findall(r'r?["\']([^"\']*?/api/[^"\']+)["\']', server)
|
||||
if "\\d" in item or ".+" in item or "(?P" in item
|
||||
)
|
||||
)
|
||||
return {"exact": exact, "prefixes": prefixes, "patterns": patterns}
|
||||
|
||||
|
||||
def database_inventory(database: str) -> list[str]:
|
||||
return re.findall(r"CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)", database)
|
||||
|
||||
|
||||
def python_functions(path: str, prefixes: tuple[str, ...]) -> list[str]:
|
||||
tree = ast.parse(source(path), filename=path)
|
||||
result: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(prefixes):
|
||||
result.append(node.name)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def css_layers(html: str) -> list[str]:
|
||||
return re.findall(r'<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"', html)
|
||||
|
||||
|
||||
def code_hotspots() -> list[dict[str, Any]]:
|
||||
candidates = [
|
||||
"server.py",
|
||||
"database.py",
|
||||
"screener.py",
|
||||
"market_insights.py",
|
||||
"tushare_client.py",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/redesign-v2.css",
|
||||
"static/renovation.css",
|
||||
"static/theme.css",
|
||||
"static/wentian-v2.css",
|
||||
]
|
||||
rows = []
|
||||
for name in candidates:
|
||||
path = ROOT / name
|
||||
rows.append(
|
||||
{
|
||||
"path": name,
|
||||
"bytes": path.stat().st_size,
|
||||
"lines": len(path.read_text(encoding="utf-8").splitlines()),
|
||||
}
|
||||
)
|
||||
return sorted(rows, key=lambda item: item["bytes"], reverse=True)
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
html = source("static/index.html")
|
||||
server = source("server.py")
|
||||
database = source("database.py")
|
||||
pages = page_inventory(html)
|
||||
api = api_inventory(server)
|
||||
tables = database_inventory(database)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_from": "governed source tree",
|
||||
"runtime": {
|
||||
"http_server": "http.server.ThreadingHTTPServer",
|
||||
"application_processes": 1,
|
||||
"database": "SQLite WAL",
|
||||
"frontend": "build-free HTML/CSS/JavaScript",
|
||||
"container_port": 8765,
|
||||
},
|
||||
"counts": {
|
||||
"primary_pages": len(pages),
|
||||
"api_exact_paths": len(api["exact"]),
|
||||
"api_prefixes": len(api["prefixes"]),
|
||||
"api_patterns": len(api["patterns"]),
|
||||
"database_tables": len(tables),
|
||||
},
|
||||
"pages": pages,
|
||||
"api": api,
|
||||
"database_tables": tables,
|
||||
"background_job_methods": python_functions(
|
||||
"server.py", ("_background", "_run_background", "_schedule_", "run_automatic")
|
||||
),
|
||||
"external_data_adapters": [
|
||||
{"provider": "tushare", "path": "tushare_client.py", "runtime_role": "primary deterministic market data"},
|
||||
{"provider": "ifind", "path": "ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
|
||||
{"provider": "eastmoney", "path": "chart_data_provider.py", "runtime_role": "display chart fallback"},
|
||||
{"provider": "eastmoney", "path": "realtime_aggregator.py", "runtime_role": "isolated realtime observation"},
|
||||
{"provider": "tencent", "path": "realtime_aggregator.py", "runtime_role": "index observation fallback"},
|
||||
],
|
||||
"llm_entrypoints": [
|
||||
{"function": "stream_with_mentor", "path": "mentor_agent.py"},
|
||||
{"function": "interpret_heaven", "path": "heaven_agent.py"},
|
||||
{"function": "stream_review_assistant", "path": "assistant_agent.py"},
|
||||
{"function": "compile_strategy_with_llm", "path": "llm_strategy.py"},
|
||||
{"function": "test_llm_connection", "path": "llm_strategy.py"},
|
||||
],
|
||||
"css_layers": css_layers(html),
|
||||
"code_hotspots": code_hotspots(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build the current architecture inventory")
|
||||
parser.add_argument("--check", action="store_true", help="fail when the committed inventory is stale")
|
||||
args = parser.parse_args()
|
||||
rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
|
||||
raise SystemExit("architecture inventory is stale; run tools/build_architecture_inventory.py")
|
||||
print("Architecture inventory is current.")
|
||||
return 0
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
print(relative(OUTPUT))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TARGET = ROOT / "app"
|
||||
OUTPUT = ROOT / "docs" / "migration" / "原版资产清单.json"
|
||||
|
||||
DIRECTORIES = (
|
||||
"backend",
|
||||
"config",
|
||||
"static",
|
||||
"tests",
|
||||
"tools",
|
||||
"vendor",
|
||||
"游资skills",
|
||||
)
|
||||
|
||||
ROOT_FILES = (
|
||||
".dockerignore",
|
||||
".env.example",
|
||||
".gitignore",
|
||||
"advanced_strategies.py",
|
||||
"alert_service.py",
|
||||
"api_access.py",
|
||||
"app_config.py",
|
||||
"ARCHITECTURE.md",
|
||||
"assistant_agent.py",
|
||||
"chart_data_provider.py",
|
||||
"compose.yaml",
|
||||
"database.py",
|
||||
"demo_data.py",
|
||||
"Dockerfile",
|
||||
"DOCKER_DEPLOY.md",
|
||||
"heaven_agent.py",
|
||||
"heaven_engine.py",
|
||||
"ifind_client.py",
|
||||
"llm_strategy.py",
|
||||
"llm_stream.py",
|
||||
"market_insights.py",
|
||||
"mentor_agent.py",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"playwright.config.js",
|
||||
"README.md",
|
||||
"realtime_aggregator.py",
|
||||
"requirements.txt",
|
||||
"screener.py",
|
||||
"security.py",
|
||||
"sentiment_engine.py",
|
||||
"server.py",
|
||||
"strategy_tracking.py",
|
||||
"sync_data.py",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"trade_journal.py",
|
||||
"tushare_client.py",
|
||||
)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
checksum = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
checksum.update(block)
|
||||
return checksum.hexdigest()
|
||||
|
||||
|
||||
def source_files() -> list[Path]:
|
||||
files = [ROOT / name for name in ROOT_FILES]
|
||||
for directory in DIRECTORIES:
|
||||
files.extend(
|
||||
path
|
||||
for path in (ROOT / directory).rglob("*")
|
||||
if path.is_file() and "__pycache__" not in path.parts
|
||||
)
|
||||
return sorted(set(files), key=lambda path: path.relative_to(ROOT).as_posix())
|
||||
|
||||
|
||||
def build_manifest() -> dict[str, object]:
|
||||
assets = []
|
||||
mismatches = []
|
||||
for source in source_files():
|
||||
relative = source.relative_to(ROOT)
|
||||
target = TARGET / relative
|
||||
source_hash = digest(source)
|
||||
target_hash = digest(target) if target.is_file() else ""
|
||||
status = "identical" if source_hash == target_hash else "mismatch"
|
||||
if status != "identical":
|
||||
mismatches.append(relative.as_posix())
|
||||
assets.append(
|
||||
{
|
||||
"source": relative.as_posix(),
|
||||
"target": f"app/{relative.as_posix()}",
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256": source_hash,
|
||||
"disposition": "original_copy_pending_move",
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"source_commit": "41329943c4878fc09ed82ec376eb93ab151e4092",
|
||||
"source_root": ".",
|
||||
"target_root": "app",
|
||||
"excluded": [
|
||||
"next/",
|
||||
"data/review.db and SQLite sidecars",
|
||||
"data/private-mentor-skills/",
|
||||
".env",
|
||||
"node_modules/",
|
||||
"logs, caches and generated test results",
|
||||
],
|
||||
"asset_count": len(assets),
|
||||
"mismatch_count": len(mismatches),
|
||||
"mismatches": mismatches,
|
||||
"assets": assets,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = build_manifest()
|
||||
OUTPUT.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
f"assets={manifest['asset_count']} mismatches={manifest['mismatch_count']} "
|
||||
f"output={OUTPUT}"
|
||||
)
|
||||
return 1 if manifest["mismatch_count"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(label: str, command: list[str]) -> None:
|
||||
print(f"\n[{label}] {' '.join(command)}", flush=True)
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def verify_database() -> None:
|
||||
database = ROOT / "data" / "review.db"
|
||||
if not database.exists():
|
||||
print("\n[database] skipped: data/review.db does not exist")
|
||||
return
|
||||
with sqlite3.connect(f"file:{database.as_posix()}?mode=ro", uri=True) as connection:
|
||||
result = connection.execute("PRAGMA integrity_check").fetchone()
|
||||
if not result or result[0] != "ok":
|
||||
raise RuntimeError(f"SQLite integrity check failed: {result}")
|
||||
print(f"\n[database] integrity_check=ok size={database.stat().st_size}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify the governance regression baseline")
|
||||
parser.add_argument(
|
||||
"--e2e",
|
||||
action="store_true",
|
||||
help="also run the Playwright browser suite",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
run("python", [sys.executable, "-m", "unittest", "discover", "-s", "tests"])
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
raise RuntimeError("node is required for JavaScript syntax checks")
|
||||
for script in ("static/app.js", "static/heaven-loading-v2.js"):
|
||||
run("javascript", [node, "--check", script])
|
||||
run("patch", ["git", "diff", "--check"])
|
||||
verify_database()
|
||||
|
||||
if args.e2e:
|
||||
npm = shutil.which("npm.cmd" if sys.platform == "win32" else "npm")
|
||||
if not npm:
|
||||
raise RuntimeError("npm is required for the Playwright suite")
|
||||
run("playwright", [npm, "run", "test:e2e"])
|
||||
print("\nBaseline verification passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user