refactor: establish standalone application boundary

This commit is contained in:
leefer
2026-08-03 21:42:25 +08:00
parent 656f28a96d
commit d6def3af15
322 changed files with 73872 additions and 44656 deletions
+14 -33
View File
@@ -1,43 +1,24 @@
# Candidate tools
# Maintenance tools
Run these commands from `webapp/app/`. The tools are separated by responsibility so a
maintenance command cannot be mistaken for a historical migration rewrite.
Run these commands from the application root. This directory contains only active startup,
registry, and verification tools.
## Normal maintenance
- `python tools/verify_baseline.py`: candidate unit tests, registry checks, every frontend
- `powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 [-Port 8797]`: start the
local application in a hidden process and keep logs, the PID, and Python cache under
`runtime/` instead of the source root.
- `python tools/verify_baseline.py`: all unit tests, registry checks, every frontend
JavaScript syntax check, Git whitespace check, and read-only SQLite integrity check.
- `python tools/verify_baseline.py --e2e`: the same checks plus Playwright. The verifier owns
the local static-server lifecycle so the command exits cleanly on Windows.
- `python tools/build_api_registry.py [--check]`: generate or verify
`config/api.config.json` from `backend/application.py`.
`config/api.config.json` from `backend/http/dispatch.py` and the registered
`backend/features/*/routes.py` owners.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the candidate source tree.
`config/architecture-inventory.json` from the current source tree.
Inside the canonical `webapp/app/` checkout, `verify_baseline.py` runs the full preservation
suite against the retained original baseline and enforces `git diff --check`. In a standalone
`app/` export where that baseline and Git checkout do not exist, the same command runs all
candidate-owned tests, skips only `test_preservation_*` comparison modules, and reports the Git
check as skipped. Product, registry, JavaScript, database, and optional Playwright checks remain
active in both modes.
## Acceptance and differential checks
- `run_preservation_runtime.py`: start an isolated original or candidate runtime with an
explicitly selected data directory and port.
- `compare_preservation_apis.py`: compare authenticated responses from two isolated runtimes.
- `compare_preservation_databases.py`: compare schema and selected table contents from two
SQLite copies.
These tools require explicit paths and do not select the production database automatically.
## Migration-only tools
- `build_preservation_manifest.py`: builds an exact-copy manifest for a specified source and
target. `--output` is mandatory so committed historical evidence is not overwritten.
- `move_class_methods.py`: mechanically moves named class methods between explicit files.
- `split_frontend_runtime.py`: reproduces the one-time Slice 10 split. It refuses to write
unless `--apply` is supplied and is not a normal maintenance command.
The migration-only tools are retained for audit and reproducibility. They are not part of
application startup, normal testing, or future feature development.
`verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside
this application. Historical comparison scripts were retired after final standalone acceptance;
their results remain under `docs/migration/evidence/` and their source remains recoverable from
Git history.
+53 -16
View File
@@ -83,23 +83,56 @@ def _mapped_paths(text: str) -> dict[str, set[str]]:
return paths
def _route_sources() -> list[tuple[Path, str]]:
paths = [ROOT / "backend" / "http" / "dispatch.py"]
paths.extend(sorted((ROOT / "backend" / "features").glob("*/routes.py")))
return [(path, path.read_text(encoding="utf-8")) for path in paths]
def _method_blocks(path: Path, text: str) -> list[tuple[str, str]]:
tree = ast.parse(text, filename=str(path))
lines = text.splitlines()
blocks: list[tuple[str, str]] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
method = ""
if node.name.startswith("do_") and node.name[3:] in {"GET", "POST", "DELETE"}:
method = node.name[3:]
else:
suffix = node.name.rsplit("_", 1)[-1].upper()
if node.name.startswith("_handle_") and suffix in {"GET", "POST", "DELETE"}:
method = suffix
if method:
blocks.append((method, "\n".join(lines[node.lineno - 1 : node.end_lineno])))
return blocks
def build() -> dict:
text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
mapped_paths = _mapped_paths(text)
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))
exact_paths.update(mapped_paths[method])
patterns = set(
re.findall(
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
block,
sources = _route_sources()
mapped_paths = {method: set() for method in ("GET", "POST", "DELETE")}
for _, text in sources:
discovered = _mapped_paths(text)
for method in mapped_paths:
mapped_paths[method].update(discovered[method])
discovered_routes = {method: {"exact": set(), "patterns": set()} for method in mapped_paths}
for path, text in sources:
for method, block in _method_blocks(path, text):
discovered_routes[method]["exact"].update(
re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block)
)
)
discovered_routes[method]["patterns"].update(
re.findall(
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
block,
)
)
routes = []
for method in ("GET", "POST", "DELETE"):
exact_paths = discovered_routes[method]["exact"] | mapped_paths[method]
patterns = discovered_routes[method]["patterns"]
for path in sorted(exact_paths):
routes.append(
{"method": method, "path": path, "match": "exact", "feature": _owner(path), "access": _role(method, path)}
@@ -110,7 +143,11 @@ def build() -> dict:
{"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}
return {
"schema_version": 1,
"generated_from": "server.py",
"routes": routes,
}
def main() -> int:
+137 -9
View File
@@ -74,6 +74,14 @@ def css_layers(html: str) -> list[str]:
return re.findall(r'<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"', html)
def frontend_fragments(registry: str) -> list[str]:
return re.findall(
r'^\s*\["[^"]+", "(/pages/[^"]+/page\.html(?:\?[^"]*)?)", \[',
registry,
re.MULTILINE,
)
def source_metrics(path: Path) -> dict[str, int]:
text = path.read_text(encoding="utf-8")
return {
@@ -85,22 +93,73 @@ def source_metrics(path: Path) -> dict[str, int]:
def code_hotspots() -> list[dict[str, Any]]:
candidates = [
"backend/application.py",
"backend/http/dispatch.py",
"backend/features/system/service.py",
"backend/features/accounts/application.py",
"backend/jobs/service.py",
"database.py",
"backend/features/screener/engine.py",
"backend/features/screener/catalog.py",
"backend/features/screener/data_sync.py",
"backend/features/screener/indicators.py",
"backend/features/screener/factors.py",
"backend/features/screener/formula.py",
"backend/features/screener/regime.py",
"backend/features/screener/selection.py",
"backend/features/screener/backtest.py",
"backend/features/market/insights.py",
"backend/features/market/insights_context.py",
"backend/features/market/insights_auction_scoring.py",
"backend/features/market/insights_auction_data.py",
"backend/features/market/insights_auction.py",
"backend/features/market/insights_themes.py",
"backend/features/market/insights_popularity.py",
"backend/data/providers/tushare_client.py",
"backend/data/providers/tushare_transport.py",
"backend/data/providers/tushare_helpers.py",
"backend/data/providers/tushare_dashboard.py",
"backend/data/providers/tushare_indices.py",
"backend/data/providers/tushare_industries.py",
"backend/data/providers/tushare_sectors.py",
"backend/data/providers/tushare_dragon_tiger.py",
"backend/data/providers/tushare_stocks.py",
"backend/data/providers/tushare_daily.py",
"backend/features/heaven/service.py",
"backend/features/heaven/manual.py",
"backend/features/heaven/trend.py",
"backend/features/heaven/market_context.py",
"backend/features/heaven/readings.py",
"backend/features/heaven/engine.py",
"frontend/index.html",
"frontend/bootstrap.js",
"frontend/pages.config.js",
"frontend/app.js",
"frontend/styles/styles.css",
"frontend/styles/redesign-v2.css",
"frontend/styles/renovation.css",
"frontend/styles/theme.css",
"frontend/pages/heaven/page.css",
"frontend/shared/context.js",
"frontend/shared/application.js",
"frontend/shared/feedback.js",
"frontend/shared/dashboard.js",
"frontend/shared/session.js",
"frontend/shared/admin.js",
"frontend/shared/theme.js",
"frontend/shared/table.js",
"frontend/pages/heaven/foundation.css",
"frontend/pages/screener/foundation.css",
"frontend/pages/auction/foundation.css",
"frontend/shared/shell.css",
"frontend/pages/heaven/page.js",
"frontend/pages/market/runtime.js",
]
candidates.extend(
relative(path)
for path in sorted((ROOT / "frontend" / "pages" / "market").glob("*.js"))
)
candidates.extend(
relative(path)
for path in sorted((ROOT / "backend" / "features").glob("*/routes.py"))
)
candidates.extend(
relative(path)
for path in sorted((ROOT / "frontend" / "pages").glob("*/page.html"))
)
rows = []
for name in candidates:
path = ROOT / name
@@ -112,7 +171,14 @@ def code_hotspots() -> list[dict[str, Any]]:
def build() -> dict[str, Any]:
html = source("frontend/index.html")
server = source("backend/application.py")
page_registry = source("frontend/pages.config.js")
fragments = frontend_fragments(page_registry)
http_sources = [source("backend/http/dispatch.py")]
http_sources.extend(
path.read_text(encoding="utf-8")
for path in sorted((ROOT / "backend" / "features").glob("*/routes.py"))
)
server = "\n".join(http_sources)
database_sources = [source("database.py")]
database_sources.extend(
path.read_text(encoding="utf-8")
@@ -140,27 +206,63 @@ def build() -> dict[str, Any]:
"api_prefixes": len(api["prefixes"]),
"api_patterns": len(api["patterns"]),
"database_tables": len(tables),
"frontend_page_fragments": len(fragments),
},
"pages": pages,
"api": api,
"database_tables": tables,
"background_job_methods": python_functions(
"backend/application.py",
"backend/jobs/service.py",
("_background", "_run_background", "_schedule_", "run_automatic"),
),
"external_data_adapters": [
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "primary deterministic market data"},
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "stable client facade for primary deterministic market data"},
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
{"provider": "eastmoney", "path": "backend/features/market/charts.py", "runtime_role": "display chart fallback"},
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"},
{"provider": "tencent", "path": "backend/data/realtime.py", "runtime_role": "index observation fallback"},
],
"provider_domains": [
{"provider": "tushare", "path": "backend/data/providers/tushare_transport.py", "responsibility": "HTTP transport and provider errors"},
{"provider": "tushare", "path": "backend/data/providers/tushare_dashboard.py", "responsibility": "market overview and realtime breadth"},
{"provider": "tushare", "path": "backend/data/providers/tushare_indices.py", "responsibility": "market indices"},
{"provider": "tushare", "path": "backend/data/providers/tushare_industries.py", "responsibility": "Shenwan membership and industry snapshots"},
{"provider": "tushare", "path": "backend/data/providers/tushare_sectors.py", "responsibility": "generic sector snapshots"},
{"provider": "tushare", "path": "backend/data/providers/tushare_dragon_tiger.py", "responsibility": "hot-money directory and dragon-tiger activity"},
{"provider": "tushare", "path": "backend/data/providers/tushare_stocks.py", "responsibility": "stock detail and intraday bars"},
{"provider": "tushare", "path": "backend/data/providers/tushare_daily.py", "responsibility": "trading calendar, daily bars, and limit lists"},
{"provider": "tushare", "path": "backend/data/providers/tushare_helpers.py", "responsibility": "shared deterministic normalization helpers"},
],
"provider_construction": [
{"client": "TushareClient", "owner": "backend/data/providers/tushare.py", "compatibility_fallback": "backend/features/market/service.py"},
{"client": "IfindHttpClient", "owner": "backend/data/gateway.py"},
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
{"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"},
],
"heaven_service_owners": {
"facade": "backend/features/heaven/service.py",
"manual_validation_and_safety": "backend/features/heaven/manual.py",
"trend_orchestration_and_quality": "backend/features/heaven/trend.py",
"market_context": "backend/features/heaven/market_context.py",
"readings_and_interpretation": "backend/features/heaven/readings.py",
},
"market_insight_owners": {
"facade": "backend/features/market/insights.py",
"shared_context": "backend/features/market/insights_context.py",
"auction_scoring": "backend/features/market/insights_auction_scoring.py",
"auction_data": "backend/features/market/insights_auction_data.py",
"auction_orchestration": "backend/features/market/insights_auction.py",
"themes": "backend/features/market/insights_themes.py",
"popularity": "backend/features/market/insights_popularity.py",
},
"application_owners": {
"composition_root": "backend/application.py",
"http_dispatch": "backend/http/dispatch.py",
"system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py",
"feature_routes": "backend/features/*/routes.py",
},
"numeric_normalization": [
{"function": "finite_number", "path": "backend/data/numbers.py"},
{"function": "non_nan_number", "path": "backend/data/numbers.py"},
@@ -184,6 +286,32 @@ def build() -> dict[str, Any]:
{"function": "send_ndjson_stream", "path": "backend/http/handler.py"},
],
"css_layers": css_layers(html),
"frontend_composition": {
"shell": "frontend/index.html",
"bootstrap": "frontend/bootstrap.js",
"registry": "frontend/pages.config.js",
"startup": "frontend/app.js",
"runtime_owners": {
"context": "frontend/shared/context.js",
"application": "frontend/shared/application.js",
"feedback": "frontend/shared/feedback.js",
"dashboard": "frontend/shared/dashboard.js",
"session": "frontend/shared/session.js",
"admin": "frontend/shared/admin.js",
"theme": "frontend/shared/theme.js",
"table": "frontend/shared/table.js",
},
"market_runtime_owners": {
"breadth": "frontend/pages/market/breadth.js",
"charts": "frontend/pages/market/charts.js",
"entity_detail": "frontend/pages/market/entity-detail.js",
"stock_detail": "frontend/pages/market/stock-detail.js",
"preview": "frontend/pages/market/preview.js",
"search": "frontend/pages/market/search.js",
"bindings": "frontend/pages/market/bindings.js",
},
"fragments": [path.split("?", 1)[0] for path in fragments],
},
"code_hotspots": code_hotspots(),
}
-182
View File
@@ -1,182 +0,0 @@
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
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 display_path(path: Path, base: Path) -> str:
try:
return path.relative_to(base).as_posix() or "."
except ValueError:
return path.as_posix()
def source_files(source_root: Path) -> list[Path]:
files = [source_root / name for name in ROOT_FILES]
for directory in DIRECTORIES:
root = source_root / directory
if not root.is_dir():
continue
files.extend(
path
for path in root.rglob("*")
if path.is_file() and "__pycache__" not in path.parts
)
return sorted(
set(files), key=lambda path: path.relative_to(source_root).as_posix()
)
def build_manifest(
source_root: Path,
target_root: Path,
source_commit: str,
) -> dict[str, object]:
assets = []
mismatches = []
for source in source_files(source_root):
relative = source.relative_to(source_root)
target = target_root / relative
source_exists = source.is_file()
source_hash = digest(source) if source_exists else ""
target_hash = digest(target) if target.is_file() else ""
if not source_exists:
status = "missing_source"
elif not target.is_file():
status = "missing_target"
elif source_hash == target_hash:
status = "identical"
else:
status = "mismatch"
if status != "identical":
mismatches.append(relative.as_posix())
assets.append(
{
"source": relative.as_posix(),
"target": display_path(target, REPOSITORY_ROOT),
"bytes": source.stat().st_size if source_exists else 0,
"sha256": source_hash,
"disposition": "original_copy_pending_move",
"status": status,
}
)
return {
"schema_version": 1,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"source_commit": source_commit,
"source_root": display_path(source_root, REPOSITORY_ROOT),
"target_root": display_path(target_root, REPOSITORY_ROOT),
"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:
parser = argparse.ArgumentParser(
description=(
"Build an exact-copy manifest for a preservation migration stage. "
"This is a migration-only tool; it does not describe the final moved layout."
)
)
parser.add_argument("--source-root", type=Path, default=REPOSITORY_ROOT)
parser.add_argument("--target-root", type=Path, default=REPOSITORY_ROOT / "app")
parser.add_argument("--source-commit", default="")
parser.add_argument(
"--output",
type=Path,
required=True,
help="write to a new audit path; do not overwrite committed slice evidence",
)
args = parser.parse_args()
source_root = args.source_root.resolve()
target_root = args.target_root.resolve()
output = args.output.resolve()
manifest = build_manifest(source_root, target_root, args.source_commit)
output.parent.mkdir(parents=True, exist_ok=True)
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())
-200
View File
@@ -1,200 +0,0 @@
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
def request_json(
opener: urllib.request.OpenerDirector,
url: str,
payload: dict[str, Any] | None = None,
method: str = "GET",
) -> tuple[int, Any]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with opener.open(request, timeout=90) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector:
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
)
status, body = request_json(
opener,
f"{base_url.rstrip('/')}/api/auth/login",
{"username": username, "password": password},
"POST",
)
if status != 200 or not body.get("ok"):
raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
csrf_token = str(body.get("csrf_token") or "")
if csrf_token:
opener.addheaders.append(("X-CSRF-Token", csrf_token))
return opener
def digest(value: Any) -> str:
content = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(content).hexdigest()
def comparable(
value: Any,
excluded_paths: set[str] | None = None,
sorted_lists: dict[str, str] | None = None,
path: str = "$",
) -> Any:
excluded_paths = excluded_paths or set()
sorted_lists = sorted_lists or {}
if isinstance(value, dict):
return {
key: comparable(
item,
excluded_paths,
sorted_lists,
f"{path}.{key}",
)
for key, item in value.items()
if key != "request_id"
and f"{path}.{key}" not in excluded_paths
}
if isinstance(value, list):
normalized = [
comparable(item, excluded_paths, sorted_lists, f"{path}[]")
for item in value
]
sort_key = sorted_lists.get(path)
if sort_key:
normalized.sort(
key=lambda item: (
str(item.get(sort_key) or "")
if isinstance(item, dict)
else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
)
)
return normalized
return value
def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None:
if type(original) is not type(migrated):
return {"path": path, "original": original, "migrated": migrated}
if isinstance(original, dict):
for key in sorted(set(original) | set(migrated)):
if key not in original or key not in migrated:
return {
"path": f"{path}.{key}",
"original": original.get(key, "<missing>"),
"migrated": migrated.get(key, "<missing>"),
}
difference = first_difference(original[key], migrated[key], f"{path}.{key}")
if difference:
return difference
return None
if isinstance(original, list):
if len(original) != len(migrated):
return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)}
for index, (original_item, migrated_item) in enumerate(zip(original, migrated)):
difference = first_difference(
original_item, migrated_item, f"{path}[{index}]"
)
if difference:
return difference
return None
if original != migrated:
return {"path": path, "original": original, "migrated": migrated}
return None
def main() -> None:
parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
parser.add_argument("--original", required=True)
parser.add_argument("--migrated", required=True)
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--requests-file", type=Path)
parser.add_argument("endpoints", nargs="*")
args = parser.parse_args()
original = session(args.original, args.username, args.password)
migrated = session(args.migrated, args.username, args.password)
rows = []
all_equal = True
requests = [
{"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None}
for endpoint in args.endpoints
]
if args.requests_file:
requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8")))
if not requests:
parser.error("provide at least one endpoint or --requests-file")
for item in requests:
endpoint = str(item["endpoint"])
method = str(item.get("method") or "GET").upper()
payload = item.get("payload")
excluded_paths = {str(path) for path in item.get("exclude_paths") or []}
sorted_lists = {
str(path): str(key)
for path, key in (item.get("sort_lists") or {}).items()
}
original_status, original_body = request_json(
original, f"{args.original.rstrip('/')}{endpoint}", payload, method
)
migrated_status, migrated_body = request_json(
migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method
)
original_comparable = comparable(
original_body, excluded_paths, sorted_lists
)
migrated_comparable = comparable(
migrated_body, excluded_paths, sorted_lists
)
equal = original_status == migrated_status and original_comparable == migrated_comparable
all_equal = all_equal and equal
rows.append(
{
"name": str(item.get("name") or endpoint),
"method": method,
"endpoint": endpoint,
"original_status": original_status,
"migrated_status": migrated_status,
"original_sha256": digest(original_comparable),
"migrated_sha256": digest(migrated_comparable),
"equal": equal,
"first_difference": (
None
if equal
else first_difference(original_comparable, migrated_comparable)
),
}
)
result = {"all_equal": all_equal, "endpoints": rows}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not all_equal:
raise SystemExit(1)
if __name__ == "__main__":
main()
-120
View File
@@ -1,120 +0,0 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
from pathlib import Path
from typing import Any
def digest(value: Any) -> str:
content = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
).encode("utf-8")
return hashlib.sha256(content).hexdigest()
def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
rows = connection.execute(
"""
SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%'
ORDER BY type, name
"""
).fetchall()
return [dict(row) for row in rows]
def table_rows(
connection: sqlite3.Connection,
table: str,
excluded_columns: set[str] | None = None,
) -> list[dict[str, Any]]:
quoted = '"' + table.replace('"', '""') + '"'
excluded_columns = excluded_columns or set()
rows = [
{
key: value
for key, value in dict(row).items()
if key not in excluded_columns
}
for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()
]
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
def main() -> None:
parser = argparse.ArgumentParser(description="Compare preservation SQLite databases")
parser.add_argument("--original", type=Path, required=True)
parser.add_argument("--migrated", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--exclude-column",
action="append",
default=[],
metavar="TABLE.COLUMN",
help="exclude a nondeterministic column from one table comparison",
)
parser.add_argument("tables", nargs="+")
args = parser.parse_args()
excluded_by_table: dict[str, set[str]] = {}
for item in args.exclude_column:
table, separator, column = item.partition(".")
if not separator or not table or not column:
parser.error("--exclude-column must use TABLE.COLUMN")
excluded_by_table.setdefault(table, set()).add(column)
original = sqlite3.connect(args.original)
migrated = sqlite3.connect(args.migrated)
original.row_factory = sqlite3.Row
migrated.row_factory = sqlite3.Row
try:
original_schema = schema(original)
migrated_schema = schema(migrated)
tables = []
all_equal = original_schema == migrated_schema
for table in args.tables:
excluded_columns = excluded_by_table.get(table, set())
original_rows = table_rows(original, table, excluded_columns)
migrated_rows = table_rows(migrated, table, excluded_columns)
equal = original_rows == migrated_rows
all_equal = all_equal and equal
tables.append(
{
"table": table,
"original_count": len(original_rows),
"migrated_count": len(migrated_rows),
"original_sha256": digest(original_rows),
"migrated_sha256": digest(migrated_rows),
"equal": equal,
"excluded_columns": sorted(excluded_columns),
}
)
result = {
"all_equal": all_equal,
"schema": {
"object_count": len(original_schema),
"original_sha256": digest(original_schema),
"migrated_sha256": digest(migrated_schema),
"equal": original_schema == migrated_schema,
},
"tables": tables,
}
finally:
original.close()
migrated.close()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not all_equal:
raise SystemExit(1)
if __name__ == "__main__":
main()
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import argparse
import ast
from pathlib import Path
MARKER = " # PRESERVATION_METHODS\n"
def method_span(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[int, int]:
start = min((decorator.lineno for decorator in node.decorator_list), default=node.lineno)
if node.end_lineno is None:
raise ValueError(f"Missing end position for {node.name}")
return start - 1, node.end_lineno
def move_methods(
source_path: Path,
class_name: str,
target_path: Path,
method_names: list[str],
) -> None:
source = source_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(source_path))
owner = next(
(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
),
None,
)
if owner is None:
raise ValueError(f"Class not found: {class_name}")
methods = {
node.name: node
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
missing = [name for name in method_names if name not in methods]
if missing:
raise ValueError(f"Methods not found in {class_name}: {', '.join(missing)}")
lines = source.splitlines(keepends=True)
ordered = sorted((methods[name] for name in method_names), key=lambda node: node.lineno)
blocks = ["".join(lines[start:end]).rstrip() for start, end in map(method_span, ordered)]
for start, end in sorted(map(method_span, ordered), reverse=True):
del lines[start:end]
while start < len(lines) - 1 and lines[start] == "\n" and lines[start + 1] == "\n":
del lines[start]
target = target_path.read_text(encoding="utf-8")
if target.count(MARKER) != 1:
raise ValueError(f"Target must contain exactly one method marker: {target_path}")
target = target.replace(MARKER, "\n\n".join(blocks) + "\n")
source_path.write_text("".join(lines), encoding="utf-8")
target_path.write_text(target, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Mechanically move class methods between modules")
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--class-name", required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("methods", nargs="+")
args = parser.parse_args()
move_methods(args.source, args.class_name, args.target, args.methods)
if __name__ == "__main__":
main()
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
import argparse
import sys
from http.server import ThreadingHTTPServer
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Run an isolated preservation runtime")
parser.add_argument("--runtime-root", type=Path, required=True)
parser.add_argument("--data-dir", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
runtime_root = args.runtime_root.resolve()
data_dir = args.data_dir.resolve()
data_dir.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, str(runtime_root))
if (runtime_root / "backend" / "bootstrap" / "config.py").is_file():
from backend.bootstrap import config
config.DATA_DIR = data_dir
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
else:
import app_config as config
config.DATA_DIR = data_dir
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
from server import RequestHandler, SERVICE
server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler)
try:
if hasattr(SERVICE, "start_background_jobs"):
SERVICE.start_background_jobs()
print(
f"Preservation runtime is running at http://127.0.0.1:{args.port} "
f"with database {SERVICE.database.path}",
flush=True,
)
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
if hasattr(SERVICE, "stop_background_jobs"):
SERVICE.stop_background_jobs()
else:
SERVICE._background_stop.set()
server.server_close()
if __name__ == "__main__":
main()
-199
View File
@@ -1,199 +0,0 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
ORIGINAL_STATIC = REPOSITORY_ROOT / "static"
FRONTEND_ROOT = REPOSITORY_ROOT / "app" / "frontend"
EVIDENCE_PATH = (
REPOSITORY_ROOT
/ "docs"
/ "migration"
/ "evidence"
/ "slice-10"
/ "frontend-source-map.json"
)
EXPECTED_SOURCE_SHA256 = "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6"
RUNTIME_RANGES: dict[str, list[tuple[int, int]]] = {
"pages/sentiment/page.js": [(1207, 1516)],
"pages/pools/page.js": [(1517, 1915)],
"pages/market/runtime.js": [(1916, 1957), (6893, 7719), (7969, 8426)],
"pages/rotation/page.js": [(1958, 2124)],
"pages/ladder/page.js": [(2125, 2216)],
"pages/auction/page.js": [(2217, 2481)],
"pages/themes/page.js": [(2482, 2583)],
"pages/popularity/page.js": [(2584, 2659)],
"pages/dragon-tiger/page.js": [(2660, 3028)],
"pages/review/page.js": [(3029, 3470), (7720, 7968)],
"pages/screener/page.js": [(3490, 4336), (6668, 6892)],
"pages/mentor/page.js": [(4337, 4827)],
"pages/heaven/page.js": [(4828, 6667)],
"shared/export.js": [(8905, 9051)],
}
def digest(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def marker(start: int, end: int, kind: str) -> str:
return f"/* PRESERVATION-SOURCE-{kind} app.js:{start}-{end} */"
def wrapped_chunk(lines: list[str], start: int, end: int) -> str:
content = "".join(lines[start - 1 : end])
return f"{marker(start, end, 'BEGIN')}\n{content}{marker(start, end, 'END')}\n"
def complement_ranges(total: int, moved: set[int]) -> list[tuple[int, int]]:
ranges: list[tuple[int, int]] = []
start = 0
for number in range(1, total + 1):
if number in moved:
if start:
ranges.append((start, number - 1))
start = 0
elif not start:
start = number
if start:
ranges.append((start, total))
return ranges
def original_prefix(relative: str) -> str:
source = ORIGINAL_STATIC / relative
if not source.is_file():
return ""
return source.read_text(encoding="utf-8").rstrip("\n") + "\n\n"
def extract_written_chunks(paths: list[Path]) -> dict[tuple[int, int], str]:
pattern = re.compile(
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
r"(.*?)"
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
re.DOTALL,
)
chunks: dict[tuple[int, int], str] = {}
for path in paths:
content = path.read_text(encoding="utf-8")
for match in pattern.finditer(content):
key = (int(match.group(1)), int(match.group(2)))
if key in chunks:
raise RuntimeError(f"Duplicate preserved range {key}")
chunks[key] = match.group(3)
return chunks
def main() -> None:
parser = argparse.ArgumentParser(
description="Reproduce the one-time Slice 10 frontend source split"
)
parser.add_argument(
"--apply",
action="store_true",
help="perform the historical split; this rewrites frontend runtime files",
)
args = parser.parse_args()
if not args.apply:
parser.error(
"this migration-only tool rewrites files; pass --apply only when "
"reproducing Slice 10 from its documented pre-split checkpoint"
)
source_path = ORIGINAL_STATIC / "app.js"
target_path = FRONTEND_ROOT / "app.js"
source_bytes = source_path.read_bytes()
source_sha256 = hashlib.sha256(source_bytes).hexdigest()
if source_sha256 != EXPECTED_SOURCE_SHA256:
raise RuntimeError(
f"Original app.js changed: expected {EXPECTED_SOURCE_SHA256}, got {source_sha256}"
)
if not target_path.is_file():
raise RuntimeError(f"Missing target runtime: {target_path}")
if hashlib.sha256(target_path.read_bytes()).hexdigest() != EXPECTED_SOURCE_SHA256:
raise RuntimeError("Target app.js is not the exact pre-split original")
source = source_bytes.decode("utf-8")
lines = source.splitlines(keepends=True)
moved_lines: set[int] = set()
for ranges in RUNTIME_RANGES.values():
for start, end in ranges:
overlap = moved_lines.intersection(range(start, end + 1))
if overlap:
raise RuntimeError(f"Overlapping source ranges at line {min(overlap)}")
moved_lines.update(range(start, end + 1))
core_ranges = complement_ranges(len(lines), moved_lines)
target_path.write_text(
"\n".join(wrapped_chunk(lines, start, end).rstrip("\n") for start, end in core_ranges)
+ "\n",
encoding="utf-8",
)
written_paths = [target_path]
for relative, ranges in RUNTIME_RANGES.items():
path = FRONTEND_ROOT / relative
path.parent.mkdir(parents=True, exist_ok=True)
chunks = "\n".join(
wrapped_chunk(lines, start, end).rstrip("\n") for start, end in ranges
)
path.write_text(original_prefix(relative) + chunks + "\n", encoding="utf-8")
written_paths.append(path)
extracted = extract_written_chunks(written_paths)
expected_ranges = {
source_range for ranges in RUNTIME_RANGES.values() for source_range in ranges
} | set(core_ranges)
if set(extracted) != expected_ranges:
raise RuntimeError("Written source ranges do not cover the original runtime exactly")
reassembled = [""] * len(lines)
for (start, end), content in extracted.items():
chunk_lines = content.splitlines(keepends=True)
if len(chunk_lines) != end - start + 1:
raise RuntimeError(f"Line count changed in preserved range {start}-{end}")
reassembled[start - 1 : end] = chunk_lines
reassembled_source = "".join(reassembled)
if reassembled_source != source:
raise RuntimeError("Split runtime cannot be reassembled byte-for-byte")
manifest = {
"source": "static/app.js",
"source_sha256": source_sha256,
"source_line_count": len(lines),
"reassembled_sha256": digest(reassembled_source),
"all_source_lines_preserved": True,
"core": {
"target": "app/frontend/app.js",
"ranges": [{"start": start, "end": end} for start, end in core_ranges],
},
"modules": [
{
"target": f"app/frontend/{relative}",
"ranges": [
{
"start": start,
"end": end,
"sha256": digest("".join(lines[start - 1 : end])),
}
for start, end in ranges
],
}
for relative, ranges in RUNTIME_RANGES.items()
],
}
EVIDENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
EVIDENCE_PATH.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
param(
[int]$Port = 8797,
[string]$BindAddress = "127.0.0.1"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$runtime = Join-Path $root "runtime"
$logs = Join-Path $runtime "logs"
$cache = Join-Path $runtime "cache\python"
New-Item -ItemType Directory -Force -Path $logs, $cache | Out-Null
$launcher = Get-Command py -ErrorAction SilentlyContinue
if (-not $launcher) {
$launcher = Get-Command python -ErrorAction Stop
}
# PowerShell 5.1 cannot start a child process when the host supplies both
# Windows' canonical "Path" key and an additional case-variant "PATH" key.
$environmentKeys = @([Environment]::GetEnvironmentVariables().Keys)
if (($environmentKeys -ccontains "Path") -and ($environmentKeys -ccontains "PATH")) {
[Environment]::SetEnvironmentVariable("PATH", $null, [EnvironmentVariableTarget]::Process)
}
$env:PYTHONPYCACHEPREFIX = $cache
$stdout = Join-Path $logs "server-$Port.log"
$stderr = Join-Path $logs "server-$Port.err.log"
function Get-ListeningProcessId {
param([int]$LocalPort)
$pattern = "^\s*TCP\s+\S+:$LocalPort\s+\S+\s+LISTENING\s+(\d+)\s*$"
$match = netstat -ano -p TCP | Select-String -Pattern $pattern | Select-Object -First 1
if ($match) {
return [int]$match.Matches[0].Groups[1].Value
}
return $null
}
$pidFile = Join-Path $runtime "server-$Port.pid"
$serverProcessId = Get-ListeningProcessId -LocalPort $Port
if ($serverProcessId) {
Set-Content -LiteralPath $pidFile -Value $serverProcessId -Encoding ascii
Write-Output "Already running PID ${serverProcessId}: http://${BindAddress}:$Port/"
Write-Output "Logs: $stdout"
exit 0
}
$childCommand = "& '$($launcher.Source.Replace("'", "''"))' -u server.py --host '$($BindAddress.Replace("'", "''"))' --port $Port 1> '$($stdout.Replace("'", "''"))' 2> '$($stderr.Replace("'", "''"))'"
$encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childCommand))
$process = Start-Process `
-FilePath (Join-Path $PSHOME "powershell.exe") `
-ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", $encodedCommand) `
-WorkingDirectory $root `
-WindowStyle Hidden `
-PassThru
$serverProcessId = $null
for ($attempt = 0; $attempt -lt 100; $attempt++) {
$listeningProcessId = Get-ListeningProcessId -LocalPort $Port
if ($listeningProcessId) {
$serverProcessId = $listeningProcessId
break
}
if ($process.HasExited) {
break
}
Start-Sleep -Milliseconds 100
}
if (-not $serverProcessId) {
$detail = Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue
throw "Server did not listen on port $Port. $detail"
}
Set-Content -LiteralPath $pidFile -Value $serverProcessId -Encoding ascii
Write-Output "Started PID ${serverProcessId}: http://${BindAddress}:$Port/"
Write-Output "Logs: $stdout"
+12 -15
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import os
import shutil
import sqlite3
import subprocess
@@ -13,6 +14,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FRONTEND_ROOT = ROOT / "frontend"
RUNTIME_ROOT = ROOT / "runtime"
E2E_URL = "http://127.0.0.1:8876/index.html"
@@ -21,19 +23,8 @@ def run(label: str, command: list[str]) -> None:
subprocess.run(command, cwd=ROOT, check=True)
def python_test_command(preservation_baseline: bool | None = None) -> list[str]:
if preservation_baseline is None:
preservation_baseline = (ROOT.parent / "static" / "app.js").is_file()
if preservation_baseline:
return [sys.executable, "-m", "unittest", "discover", "-s", "tests"]
modules = [
f"tests.{path.stem}"
for path in sorted((ROOT / "tests").glob("test_*.py"))
if not path.stem.startswith("test_preservation_")
]
if not modules:
raise RuntimeError("no standalone candidate tests found")
return [sys.executable, "-m", "unittest", *modules]
def python_test_command() -> list[str]:
return [sys.executable, "-m", "unittest", "discover", "-s", "tests"]
def verify_git_diff() -> None:
@@ -121,7 +112,7 @@ def stop_e2e_server(process: subprocess.Popen[bytes] | None) -> None:
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify the modular preservation candidate"
description="Verify the standalone application"
)
parser.add_argument(
"--e2e",
@@ -130,6 +121,10 @@ def main() -> int:
)
args = parser.parse_args()
cache_root = RUNTIME_ROOT / "cache" / "python"
cache_root.mkdir(parents=True, exist_ok=True)
os.environ["PYTHONPYCACHEPREFIX"] = str(cache_root)
run("python", python_test_command())
run(
"api-registry",
@@ -142,7 +137,9 @@ def main() -> int:
node = shutil.which("node")
if not node:
raise RuntimeError("node is required for JavaScript syntax checks")
scripts = sorted(FRONTEND_ROOT.rglob("*.js"))
scripts = sorted(
path for path in FRONTEND_ROOT.rglob("*") if path.suffix in {".js", ".mjs"}
)
if not scripts:
raise RuntimeError(f"no JavaScript files found under {FRONTEND_ROOT}")
for script in scripts: