refactor: establish standalone application boundary
This commit is contained in:
+14
-33
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user