migration: preserve frontend shell pages and styles
This commit is contained in:
@@ -75,13 +75,15 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
"screener.py",
|
||||
"market_insights.py",
|
||||
"tushare_client.py",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/redesign-v2.css",
|
||||
"static/renovation.css",
|
||||
"static/theme.css",
|
||||
"static/wentian-v2.css",
|
||||
"frontend/index.html",
|
||||
"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/pages/heaven/page.js",
|
||||
"frontend/pages/market/runtime.js",
|
||||
]
|
||||
rows = []
|
||||
for name in candidates:
|
||||
@@ -97,7 +99,7 @@ def code_hotspots() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
html = source("static/index.html")
|
||||
html = source("frontend/index.html")
|
||||
server = source("server.py")
|
||||
database = source("database.py")
|
||||
pages = page_inventory(html)
|
||||
|
||||
@@ -27,9 +27,21 @@ def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
|
||||
def table_rows(
|
||||
connection: sqlite3.Connection,
|
||||
table: str,
|
||||
excluded_columns: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
quoted = '"' + table.replace('"', '""') + '"'
|
||||
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
|
||||
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))
|
||||
|
||||
|
||||
@@ -38,9 +50,23 @@ def main() -> None:
|
||||
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
|
||||
@@ -51,8 +77,9 @@ def main() -> None:
|
||||
tables = []
|
||||
all_equal = original_schema == migrated_schema
|
||||
for table in args.tables:
|
||||
original_rows = table_rows(original, table)
|
||||
migrated_rows = table_rows(migrated, table)
|
||||
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(
|
||||
@@ -63,6 +90,7 @@ def main() -> None:
|
||||
"original_sha256": digest(original_rows),
|
||||
"migrated_sha256": digest(migrated_rows),
|
||||
"equal": equal,
|
||||
"excluded_columns": sorted(excluded_columns),
|
||||
}
|
||||
)
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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:
|
||||
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()
|
||||
Reference in New Issue
Block a user