каркас: домен, генерация типов фронта, стенд и карточки задач
Контракт в коде: КИО по нормативу, 43 события WS по шести союзам направлений, таймеры ГОСТ, таксономия E1–E6, шесть компетенций радара. make types гоняет их через JSON Schema в generated.ts, тест ловит забытую регенерацию. Стенд: docker compose postgres + backend, health отвечает, база досягаема из контейнера бэкенда. Makefile не врёт — нереализованные цели падают и называют карточку, которая их закроет. frontend/.npmrc: под WSL2 npm install зависает на дефолтных 15 сокетах.
This commit is contained in:
parent
4425c7d4ca
commit
7b7dd38e9f
36 changed files with 4997 additions and 0 deletions
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
159
backend/scripts/export_types.py
Normal file
159
backend/scripts/export_types.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""make types: app/domain/events.py → frontend/src/shared/types/generated.ts
|
||||
|
||||
Единственный шов между треками генерируется, а не переписывается руками
|
||||
(docs/arch/CONTRACT.md). Запускается без тяжёлых зависимостей — нужен только
|
||||
pydantic, поэтому годится и в pre-commit, и в CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.domain.events import EventCatalog # noqa: E402
|
||||
|
||||
OUT = ROOT.parent / "frontend" / "src" / "shared" / "types" / "generated.ts"
|
||||
|
||||
HEADER = """// Сгенерировано `make types` из backend/app/domain/events.py.
|
||||
// Руками не править: правка уедет при следующей генерации.
|
||||
// Контракт: docs/arch/CONTRACT.md
|
||||
"""
|
||||
|
||||
|
||||
def pascal(name: str) -> str:
|
||||
return "".join(part.capitalize() for part in name.split("_"))
|
||||
|
||||
|
||||
def literal(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def ts_type(node: Any) -> str:
|
||||
"""JSON Schema → выражение типа TypeScript."""
|
||||
if node is True or node is None:
|
||||
return "unknown"
|
||||
if node is False:
|
||||
return "never"
|
||||
if "$ref" in node:
|
||||
return node["$ref"].rsplit("/", 1)[-1]
|
||||
if "allOf" in node and len(node["allOf"]) == 1:
|
||||
return ts_type(node["allOf"][0])
|
||||
if "anyOf" in node:
|
||||
return " | ".join(dict.fromkeys(ts_type(item) for item in node["anyOf"]))
|
||||
if "oneOf" in node:
|
||||
return " | ".join(dict.fromkeys(ts_type(item) for item in node["oneOf"]))
|
||||
if "const" in node:
|
||||
return literal(node["const"])
|
||||
if "enum" in node:
|
||||
return " | ".join(literal(value) for value in node["enum"])
|
||||
|
||||
kind = node.get("type")
|
||||
if kind == "array":
|
||||
if "prefixItems" in node:
|
||||
return "[" + ", ".join(ts_type(item) for item in node["prefixItems"]) + "]"
|
||||
return f"Array<{ts_type(node.get('items'))}>"
|
||||
if kind == "object":
|
||||
extra = node.get("additionalProperties")
|
||||
if "properties" in node:
|
||||
return inline_object(node)
|
||||
return f"Record<string, {ts_type(extra)}>"
|
||||
if kind == "string":
|
||||
return "string"
|
||||
if kind in ("integer", "number"):
|
||||
return "number"
|
||||
if kind == "boolean":
|
||||
return "boolean"
|
||||
if kind == "null":
|
||||
return "null"
|
||||
if isinstance(kind, list):
|
||||
return " | ".join(ts_type({"type": item}) for item in kind)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def fields(node: dict[str, Any], indent: str = " ") -> list[str]:
|
||||
required = set(node.get("required", []))
|
||||
lines: list[str] = []
|
||||
for name, prop in node.get("properties", {}).items():
|
||||
# Дискриминатор всегда присутствует в сериализованном событии,
|
||||
# иначе TypeScript не сузит союз по полю `type`.
|
||||
optional = "" if name in required or "const" in prop else "?"
|
||||
description = prop.get("description")
|
||||
if description:
|
||||
lines.append(f"{indent}/** {description} */")
|
||||
lines.append(f"{indent}{name}{optional}: {ts_type(prop)};")
|
||||
return lines
|
||||
|
||||
|
||||
def inline_object(node: dict[str, Any]) -> str:
|
||||
return "{ " + " ".join(line.strip() for line in fields(node, indent="")) + " }"
|
||||
|
||||
|
||||
def render_def(name: str, node: dict[str, Any]) -> str:
|
||||
doc = node.get("description", "").strip()
|
||||
comment = f"/** {doc} */\n" if doc else ""
|
||||
if "enum" in node and "properties" not in node:
|
||||
values = " | ".join(literal(value) for value in node["enum"])
|
||||
return f"{comment}export type {name} = {values};"
|
||||
if "properties" not in node:
|
||||
return f"{comment}export type {name} = {ts_type(node)};"
|
||||
body = "\n".join(fields(node))
|
||||
return f"{comment}export interface {name} {{\n{body}\n}}"
|
||||
|
||||
|
||||
def event_names(defs: dict[str, Any]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for node in defs.values():
|
||||
prop = node.get("properties", {}).get("type", {})
|
||||
value = prop.get("const")
|
||||
if value is None and isinstance(prop.get("enum"), list) and len(prop["enum"]) == 1:
|
||||
value = prop["enum"][0]
|
||||
if isinstance(value, str) and value not in names:
|
||||
names.append(value)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""Готовый текст generated.ts. Вынесено отдельно, чтобы тест мог
|
||||
сверить файл в репозитории с моделями и поймать забытый make types."""
|
||||
schema = EventCatalog.model_json_schema(ref_template="#/$defs/{model}")
|
||||
defs: dict[str, Any] = schema.get("$defs", {})
|
||||
|
||||
chunks = [HEADER]
|
||||
for name in sorted(defs):
|
||||
chunks.append(render_def(name, defs[name]))
|
||||
|
||||
chunks.append("// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы")
|
||||
for key, prop in schema.get("properties", {}).items():
|
||||
alias, expr = pascal(key), ts_type(prop)
|
||||
if alias == expr:
|
||||
continue # союз из одного типа: интерфейс уже объявлен выше
|
||||
chunks.append(f"export type {alias} = {expr};")
|
||||
|
||||
names = event_names(defs)
|
||||
chunks.append(
|
||||
"export type EventName =\n " + "\n | ".join(literal(name) for name in names) + ";"
|
||||
)
|
||||
|
||||
return "\n\n".join(chunks) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
text = render()
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(text, encoding="utf-8")
|
||||
print(f"{OUT.relative_to(ROOT.parent)}: {text.count('export ')} объявлений")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue