opencode-config/.opencode/scripts/spec-status.py
Sergey c0e8ec816a
refactor(scripts): unify project contract + fix no_db polarity + frontend stack detection (#267)
* refactor(scripts): add project_contract.py single source of truth

* fix(spec-status): word-boundary regex for stack matching (uv!=uvicorn)

* feat(project-status): frontend stack detection + no_db support

* fix(template): cookiecutter use_db=no writes no_db=true marker

* fix(ci): ruff SIM300/PLC0415/W292/E501 in project_contract tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 23:20:11 +03:00

456 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Spec-status oracle: determine current phase of a project spec.
Reads facts from ``docs/spec/*.md`` files (one file per phase) to
deterministically derive the current spec phase — no state file, like
``git status`` for the spec pipeline. Repo-aware via
``_resolve_repo_root()`` (cwd-aware, ADR-010) and ``get_repo_full_name()``
via ``git remote get-url origin`` + ``@functools.cache`` (ADR-007).
``gh issue view --repo <org/repo>`` in Phase 8 (ADR-023).
Usage:
python3 config/scripts/spec-status.py # current phase
python3 config/scripts/spec-status.py --validate # all phases detail
Structure of ``docs/spec/``:
meta.md frontmatter: project, type, created, phase, confirmed,
executed, no_db
context.md Phase 1: project description
stack.md Phase 2: stack (default + choices)
modules.md Phase 3: modules + structure tree
db-schema.md Phase 4 (optional — absent if no_db: true)
infra.md Phase 5: infra
roadmap.md Phase 6: roadmap with #N issue numbers
Nine phases:
0. DETECT — docs/spec/meta.md exists + frontmatter project: key
1. PROJECT_TYPE — type/project in frontmatter + context.md filled
2. STACK — stack.md filled + mandatory items per project type
3. MODULES — modules.md filled + >=1 bullet item
4. DB_SCHEMA — no_db: true OR db-schema.md filled
5. INFRA — infra.md filled (>=3 chars)
6. ROADMAP — roadmap.md filled + >=1 bullet item
7. CONFIRM — confirmed: true in meta.md frontmatter
8. EXECUTE — executed: true + roadmap #N issues exist (gh view)
"""
from __future__ import annotations
import functools
import importlib.util
import re
import subprocess
import sys
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
# Load project_contract.py via importlib.util (no sys.path mutation).
_contract_path = Path(__file__).resolve().parent / "project_contract.py"
_pc_spec = importlib.util.spec_from_file_location("project_contract", _contract_path)
project_contract = importlib.util.module_from_spec(_pc_spec) # type: ignore[arg-type]
_pc_spec.loader.exec_module(project_contract) # type: ignore[union-attr]
def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location."""
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False
)
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
return Path(__file__).resolve().parent.parent.parent
REPO_ROOT = _resolve_repo_root()
SPEC_DIR = REPO_ROOT / "docs" / "spec"
META_FILE = SPEC_DIR / "meta.md"
PHASE_FILES: dict[int, str] = {
1: "context.md",
2: "stack.md",
3: "modules.md",
4: "db-schema.md",
5: "infra.md",
6: "roadmap.md",
}
# Re-exported from project_contract.py for backward compatibility
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
VALID_TYPES = project_contract.VALID_TYPES
STACK_REQUIRED = project_contract.STACK_REQUIRED
PHASE_NAMES = [
"DETECT",
"PROJECT_TYPE",
"STACK",
"MODULES",
"DB_SCHEMA",
"INFRA",
"ROADMAP",
"CONFIRM",
"EXECUTE",
]
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
class PhaseStatus(StrEnum):
"""Phase check result."""
DONE = "DONE"
NOT_DONE = "NOT_DONE"
AMBIGUOUS = "AMBIGUOUS"
@dataclass(frozen=True)
class PhaseResult:
"""Result of a single phase check."""
status: PhaseStatus
detail: str
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
def parse_remote_url(url: str) -> tuple[str, str, str]:
"""Parse git remote URL into (host, org, repo).
Supports both HTTPS and SSH formats:
https://github.com/org/repo.git -> (github.com, org, repo)
git@github.com:org/repo.git -> (github.com, org, repo)
"""
ssh_match = re.match(r"git@([^:]+):([^/]+)/(.+?)(?:\.git)?$", url)
if ssh_match:
return ssh_match.group(1), ssh_match.group(2), ssh_match.group(3)
# `(?:[^/@]*@)?` optionally skips `user:password@` userinfo before host.
# Needed because `git config url.insteadOf` rewrites `https://github.com/`
# to `https://x-access-token:TOKEN@github.com/`, and `git remote get-url
# origin` returns the rewritten URL (see ADR-022 / ADR-023).
https_match = re.match(r"https?://(?:[^/@]*@)?([^/]+)/([^/]+)/(.+?)(?:\.git)?$", url)
if https_match:
return https_match.group(1), https_match.group(2), https_match.group(3)
raise ValueError(f"Cannot parse remote URL: {url}")
@functools.cache
def get_repo_full_name() -> str:
"""Return ``org/repo`` from git remote (cached, one call per run).
Used for ``gh issue view --repo <org/repo>`` in Phase 8. Cached via
``functools.cache`` (one git call per process); tests reset via
``get_repo_full_name.cache_clear()``.
"""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
_host, org, repo = parse_remote_url(out.strip())
return f"{org}/{repo}"
def parse_frontmatter(content: str) -> dict[str, str]:
"""Parse simple key: value frontmatter (no nested structures).
Strips one balanced pair of surrounding single/double quotes from each
value (e.g. ``type: 'fullstack'`` -> ``fullstack``). Unbalanced quotes
are preserved verbatim.
"""
match = FRONTMATTER_RE.search(content)
if not match:
return {}
fm_text = match.group(1)
parsed: dict[str, str] = {}
for key, raw in KV_RE.findall(fm_text):
value = raw.strip()
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
value = value[1:-1]
parsed[key] = value
return parsed
def read_meta() -> tuple[str, dict[str, str]]:
"""Read docs/spec/meta.md content + parsed frontmatter.
Returns ``("", {})`` if meta.md is missing. Uses ``utf-8-sig`` to
transparently strip a leading BOM if present.
"""
if not META_FILE.exists():
return "", {}
content = META_FILE.read_text(encoding="utf-8-sig")
return content, parse_frontmatter(content)
def file_filled(path: Path) -> bool:
"""Return True if path exists and its text content (stripped) is non-empty."""
return path.exists() and bool(path.read_text().strip())
def has_bullet_items(path: Path) -> bool:
"""Return True if file contains >=1 line starting with ``-`` or ``*``."""
if not path.exists():
return False
return any(line.lstrip().startswith(("-", "*")) for line in path.read_text().splitlines())
def check_detect() -> PhaseResult:
"""Phase 0: DETECT — meta.md exists + frontmatter + project: key."""
if not META_FILE.exists():
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/meta.md не найден — инициализируй spec")
_content, fm = read_meta()
if not fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter пустой — инициализируй spec")
if "project" not in fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter: ключ project: отсутствует")
return PhaseResult(PhaseStatus.DONE, "spec инициализирован")
def check_project_type() -> PhaseResult:
"""Phase 1: PROJECT_TYPE — type/project in frontmatter + context.md filled."""
_content, fm = read_meta()
if not fm:
return PhaseResult(PhaseStatus.NOT_DONE, "frontmatter пустой")
ptype = fm.get("type", "").strip()
project = fm.get("project", "").strip()
if not ptype or not project:
return PhaseResult(PhaseStatus.NOT_DONE, "type: или project: пустой в frontmatter")
if ptype not in VALID_TYPES:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"type={ptype} невалиден (допустимо: {', '.join(sorted(VALID_TYPES))})",
)
context_file = SPEC_DIR / PHASE_FILES[1]
if not file_filled(context_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/context.md не заполнен")
return PhaseResult(PhaseStatus.DONE, f"{ptype} выбран, project={project}")
def check_stack() -> PhaseResult:
"""Phase 2: STACK — stack.md filled + mandatory items per project type."""
_content, fm = read_meta()
ptype = fm.get("type", "").strip()
if ptype not in VALID_TYPES:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"type={ptype or ''} невалиден, STACK нельзя проверить",
)
stack_file = SPEC_DIR / PHASE_FILES[2]
if not file_filled(stack_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
required = STACK_REQUIRED[ptype]
stack_body = stack_file.read_text()
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind``
# does NOT match ``tailwindcss``. Case-insensitive.
missing = [
item
for item in required
if not re.search(rf"\b{re.escape(item)}\b", stack_body, re.IGNORECASE)
]
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"не хватает mandatory items: {', '.join(missing)}",
)
return PhaseResult(PhaseStatus.DONE, f"все {len(required)} mandatory items присутствуют")
def check_modules() -> PhaseResult:
"""Phase 3: MODULES — modules.md filled + >=1 bullet item."""
modules_file = SPEC_DIR / PHASE_FILES[3]
if not file_filled(modules_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/modules.md не заполнен")
if not has_bullet_items(modules_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/modules.md без пунктов (нужен '-' или '*')",
)
bullets = [
line
for line in modules_file.read_text().splitlines()
if line.lstrip().startswith(("-", "*"))
]
return PhaseResult(PhaseStatus.DONE, f"{len(bullets)} модул(ей)")
def check_db_schema() -> PhaseResult:
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
_content, fm = read_meta()
if fm.get("no_db", "").strip().lower() == "true":
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
db_file = SPEC_DIR / PHASE_FILES[4]
if not file_filled(db_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/db-schema.md не заполнен (или no_db: true в frontmatter)",
)
return PhaseResult(PhaseStatus.DONE, "docs/spec/db-schema.md заполнен")
def check_infra() -> PhaseResult:
"""Phase 5: INFRA — infra.md filled (>=3 chars)."""
infra_file = SPEC_DIR / PHASE_FILES[5]
if not file_filled(infra_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/infra.md не заполнен")
body = infra_file.read_text().strip()
if len(body) < 3:
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/infra.md пустой (<3 символов)")
return PhaseResult(PhaseStatus.DONE, "docs/spec/infra.md заполнен")
def check_roadmap() -> PhaseResult:
"""Phase 6: ROADMAP — roadmap.md filled + >=1 bullet item."""
roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not file_filled(roadmap_file):
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/roadmap.md не заполнен")
if not has_bullet_items(roadmap_file):
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/spec/roadmap.md без пунктов (нужен '-' или '*')",
)
bullets = [
line
for line in roadmap_file.read_text().splitlines()
if line.lstrip().startswith(("-", "*"))
]
return PhaseResult(PhaseStatus.DONE, f"{len(bullets)} пунктов в roadmap")
def check_confirm() -> PhaseResult:
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
_content, fm = read_meta()
val = fm.get("confirmed", "").strip().lower()
if val != "true":
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
def _extract_issue_numbers(content: str) -> list[int]:
"""Extract #N issue references from roadmap.md content."""
return [int(m) for m in re.findall(r"#(\d+)", content)]
def _check_roadmap_issues(repo: str, issue_nums: list[int]) -> PhaseResult:
"""Phase 8 part: verify each #N issue exists via ``gh issue view --repo``.
Returns DONE if all issues found, NOT_DONE if any missing.
"""
missing: list[int] = []
for num in issue_nums:
rc, _, _ = run_cmd(["gh", "issue", "view", str(num), "--repo", repo])
if rc != 0:
missing.append(num)
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE,
f"issues не созданы/не найдены: {', '.join(f'#{n}' for n in missing)}",
)
return PhaseResult(PhaseStatus.DONE, f"все {len(issue_nums)} issues созданы")
def check_execute() -> PhaseResult:
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
_content, fm = read_meta()
val = fm.get("executed", "").strip().lower()
if val != "true":
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
roadmap_file = SPEC_DIR / PHASE_FILES[6]
if not roadmap_file.exists():
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/roadmap.md не найден для проверки #N")
issue_nums = _extract_issue_numbers(roadmap_file.read_text())
if not issue_nums:
return PhaseResult(PhaseStatus.NOT_DONE, "в roadmap.md нет #N ссылок для проверки")
try:
repo = get_repo_full_name()
except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.AMBIGUOUS, f"git remote error: {exc}")
return _check_roadmap_issues(repo, issue_nums)
PHASE_CHECKS = [
check_detect,
check_project_type,
check_stack,
check_modules,
check_db_schema,
check_infra,
check_roadmap,
check_confirm,
check_execute,
]
NEXT_ACTIONS: dict[str, str] = {
"DETECT": "создай docs/spec/meta.md с frontmatter (project, type, created, phase, status)",
"PROJECT_TYPE": "запроси у юзера тип + имя, заполни meta.md frontmatter + создай context.md",
"STACK": "создай docs/spec/stack.md (default stack для типа + choices юзера)",
"MODULES": "запроси модули, создай docs/spec/modules.md (## Модули + ## Структура)",
"DB_SCHEMA": "создай docs/spec/db-schema.md (или no_db: true в meta.md)",
"INFRA": "создай docs/spec/infra.md (Docker, Prefect, MCP, Tunnel)",
"ROADMAP": "создай docs/spec/roadmap.md — N пунктов для будущих issues",
"CONFIRM": "покажи spec юзеру, поставь confirmed: true в meta.md после подтверждения",
"EXECUTE": "создай GitHub issues по roadmap, поставь executed: true в meta.md",
}
STATUS_ICONS: dict[PhaseStatus, str] = {
PhaseStatus.DONE: "",
PhaseStatus.NOT_DONE: "",
PhaseStatus.AMBIGUOUS: "⚠️",
}
def find_current_phase(results: list[PhaseResult]) -> int | None:
"""Return index of first not-done phase, or None if all done."""
for i, result in enumerate(results):
if result.status != PhaseStatus.DONE:
return i
return None
def run_all_checks() -> list[PhaseResult]:
"""Run all 9 phase checks, return results in order."""
return [check() for check in PHASE_CHECKS]
def format_output(results: list[PhaseResult], fm: dict[str, str]) -> str:
"""Format output: 9 phase lines + NEXT or COMPLETE."""
validate = "--validate" in sys.argv[1:]
project = fm.get("project", "").strip() or "<unnamed>"
lines: list[str] = [f"Spec: {project}", ""]
for i, (name, result) in enumerate(zip(PHASE_NAMES, results, strict=True)):
icon = STATUS_ICONS[result.status]
if validate or result.status != PhaseStatus.DONE:
lines.append(f"{icon} {i}. {name:<12} {result.detail}")
else:
lines.append(f"{icon} {i}. {name:<12} done")
lines.append("")
current = find_current_phase(results)
if current is None:
lines.append("Status: COMPLETE")
return "\n".join(lines)
result = results[current]
phase_name = PHASE_NAMES[current]
if result.status == PhaseStatus.AMBIGUOUS:
lines.append(f"AMBIGUOUS: {result.detail}")
lines.append("NEXT: уточните статус вручную")
else:
action = NEXT_ACTIONS.get(phase_name, "уточнить статус")
lines.append(f"NEXT: {action} (Phase {current})")
return "\n".join(lines)
def main() -> None:
"""Entry point: parse args, run checks, print status."""
results = run_all_checks()
_content, fm = read_meta()
print(format_output(results, fm))
if __name__ == "__main__":
main()