feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Machine-readable acceptance evidence derived from repository state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.script_catalog import ScriptCatalog
|
||||
from gyxx_flow.security import scan_repository
|
||||
|
||||
_CHECKLIST = re.compile(r"^\s*-\s*\[([ xX])\]\s+(P\d+\.\d+)\b", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlanItem:
|
||||
item_id: str
|
||||
completed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceReport:
|
||||
items: tuple[PlanItem, ...]
|
||||
checks: dict[str, bool]
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return bool(self.items) and all(item.completed for item in self.items) and all(
|
||||
self.checks.values()
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
completed = sum(item.completed for item in self.items)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"is_complete": self.is_complete,
|
||||
"summary": {
|
||||
"total": len(self.items),
|
||||
"completed": completed,
|
||||
"pending": len(self.items) - completed,
|
||||
},
|
||||
"items": [asdict(item) for item in self.items],
|
||||
"checks": dict(sorted(self.checks.items())),
|
||||
}
|
||||
|
||||
|
||||
def parse_plan_checklist(path: Path) -> tuple[PlanItem, ...]:
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
items = tuple(
|
||||
PlanItem(item_id=match.group(2), completed=match.group(1).casefold() == "x")
|
||||
for match in _CHECKLIST.finditer(text)
|
||||
)
|
||||
if len({item.item_id for item in items}) != len(items):
|
||||
raise ValueError("plan contains duplicate acceptance item IDs")
|
||||
return items
|
||||
|
||||
|
||||
def build_acceptance_report(settings: Settings) -> AcceptanceReport:
|
||||
project_root = settings.project_root
|
||||
items = parse_plan_checklist(project_root / "plan.md")
|
||||
checks = {
|
||||
"catalog_21_tasks": _catalog_has_21_tasks(project_root),
|
||||
"baseline_21_tasks": _baseline_has_21_tasks(settings.data_root),
|
||||
"native_entrypoints_local": _native_entrypoints_are_local(project_root),
|
||||
"runtime_sources_decoupled": _runtime_sources_are_decoupled(project_root),
|
||||
"runnable_script_catalog": _runnable_script_catalog_is_complete(),
|
||||
"source_manifests_verified": _source_manifests_are_verified(project_root),
|
||||
"secret_scan_clean": not scan_repository(project_root),
|
||||
}
|
||||
return AcceptanceReport(items, checks)
|
||||
|
||||
|
||||
def _catalog_has_21_tasks(project_root: Path) -> bool:
|
||||
try:
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
except Exception:
|
||||
return False
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
return len(scheduled) == 21 and len(catalog.schedules) == 21
|
||||
|
||||
|
||||
def _baseline_has_21_tasks(data_root: Path) -> bool:
|
||||
candidates = sorted((Path(data_root) / "baseline").glob("*/manifest.json"))
|
||||
if not candidates:
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(candidates[-1].read_text(encoding="utf-8"))
|
||||
tasks = payload["scheduled_tasks"]
|
||||
return tasks["actual_count"] == 21 and len(tasks["tasks"]) == 21
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
try:
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
for workflow in catalog.workflows:
|
||||
if workflow.trigger == "unavailable":
|
||||
continue
|
||||
target = (
|
||||
project_root
|
||||
/ "src"
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ workflow.module
|
||||
/ "runtime"
|
||||
/ workflow.entry
|
||||
).resolve(strict=True)
|
||||
if not target.is_file() or not target.is_relative_to(project_root):
|
||||
return False
|
||||
config_text = (project_root / "config" / "workflows.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
return "legacy" not in config_text.casefold()
|
||||
except (OSError, ValueError, CatalogError):
|
||||
return False
|
||||
|
||||
|
||||
def _runnable_script_catalog_is_complete() -> bool:
|
||||
try:
|
||||
scripts = ScriptCatalog.discover_default().scripts
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return len(scripts) >= 100 and len({item.module for item in scripts}) == 4
|
||||
|
||||
|
||||
def _runtime_sources_are_decoupled(project_root: Path) -> bool:
|
||||
module_root = project_root / "src" / "gyxx_flow" / "modules"
|
||||
forbidden = (
|
||||
"d:\\yingxiaoyunying",
|
||||
"d:\\shop-data-flow",
|
||||
"d:\\product-collector-analyze-flow",
|
||||
"e:\\auto-flow",
|
||||
"gyxx_legacy_",
|
||||
"deferredlegacycommandstep",
|
||||
)
|
||||
try:
|
||||
for path in module_root.rglob("*"):
|
||||
if not path.is_file() or path.suffix.casefold() in {".pyc", ".pyo"}:
|
||||
continue
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(project_root):
|
||||
return False
|
||||
content = path.read_bytes()
|
||||
if b"\x00" in content:
|
||||
continue
|
||||
text: str | None = None
|
||||
for encoding in ("utf-8-sig", "gb18030"):
|
||||
try:
|
||||
text = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if text is None:
|
||||
continue
|
||||
normalized = text.casefold().replace("/", "\\")
|
||||
while "\\\\" in normalized:
|
||||
normalized = normalized.replace("\\\\", "\\")
|
||||
if any(value in normalized for value in forbidden):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _source_manifests_are_verified(project_root: Path) -> bool:
|
||||
modules = (
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
"shop_intelligence",
|
||||
"supply_chain",
|
||||
)
|
||||
try:
|
||||
for module in modules:
|
||||
payload = json.loads(
|
||||
(
|
||||
project_root
|
||||
/ "config"
|
||||
/ "source-manifests"
|
||||
/ f"{module}.json"
|
||||
).read_text(encoding="utf-8-sig")
|
||||
)
|
||||
if payload.get("schema_version") != 1 or payload.get("module") != module:
|
||||
return False
|
||||
files = payload.get("files")
|
||||
if not isinstance(files, list) or not files:
|
||||
return False
|
||||
for item in files:
|
||||
target = project_root.joinpath(*Path(item["target_relative_path"]).parts)
|
||||
resolved = target.resolve(strict=True)
|
||||
if (
|
||||
target.is_symlink()
|
||||
or not resolved.is_relative_to(project_root)
|
||||
or not resolved.is_file()
|
||||
or _sha256(resolved) != item["target_sha256"]
|
||||
):
|
||||
return False
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
Reference in New Issue
Block a user