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>
This commit is contained in:
parent
7c3ded601f
commit
c0e8ec816a
9 changed files with 518 additions and 51 deletions
|
|
@ -31,6 +31,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ast
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -40,6 +42,12 @@ from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
|
||||||
# ── repo root + config ─────────────────────────────────────────────────────────
|
# ── repo root + config ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,6 +75,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"route_line_limit": 50,
|
"route_line_limit": 50,
|
||||||
"min_test_count": 1,
|
"min_test_count": 1,
|
||||||
"require_branch_protection": False,
|
"require_branch_protection": False,
|
||||||
|
"no_db": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,6 +84,10 @@ def load_config(root: Path | None = None) -> dict[str, Any]:
|
||||||
|
|
||||||
Falls back to ``DEFAULT_CONFIG`` if the section or file is missing.
|
Falls back to ``DEFAULT_CONFIG`` if the section or file is missing.
|
||||||
Uses ``tomllib`` (stdlib, Python 3.11+). Reads only — never writes.
|
Uses ``tomllib`` (stdlib, Python 3.11+). Reads only — never writes.
|
||||||
|
|
||||||
|
Supports ``no_db: bool`` — when true, BACKEND skips the ``db/models``
|
||||||
|
structure check (cookiecutter ``use_db=no`` writes this marker in
|
||||||
|
``post_gen_project.py``).
|
||||||
"""
|
"""
|
||||||
base = root if root is not None else REPO_ROOT
|
base = root if root is not None else REPO_ROOT
|
||||||
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
|
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||||
|
|
@ -109,15 +122,9 @@ class CheckStatus(StrEnum):
|
||||||
FAIL = "FAIL"
|
FAIL = "FAIL"
|
||||||
|
|
||||||
|
|
||||||
class ProjectType(StrEnum):
|
# Re-exported from project_contract.py for backward compatibility
|
||||||
"""Auto-detected project type."""
|
# (tests use ``ps.ProjectType.X``).
|
||||||
|
ProjectType = project_contract.ProjectType
|
||||||
FULLSTACK = "fullstack"
|
|
||||||
BACKEND = "backend"
|
|
||||||
CLI = "cli"
|
|
||||||
BOT = "bot"
|
|
||||||
WORKER = "worker"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -303,24 +310,20 @@ def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType:
|
||||||
# ── expected structure per type ──────────────────────────────────────────────
|
# ── expected structure per type ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
|
# Re-exported from project_contract.py for backward compatibility
|
||||||
# BACKEND is resolved dynamically by ``_expected_backend_paths`` — the
|
# (tests use ``ps.STRUCTURE_EXPECTED``).
|
||||||
# paths are ``src/<package_name>/...`` where ``<package_name>`` comes from
|
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED
|
||||||
# ``[project].name`` normalized (``my-project`` → ``my_project``).
|
|
||||||
ProjectType.FULLSTACK: ["backend", "frontend"],
|
|
||||||
ProjectType.CLI: ["src"], # src/<package>/ — checked generically
|
|
||||||
ProjectType.BOT: ["src/bot.py"],
|
|
||||||
ProjectType.WORKER: ["src/flow.py"],
|
|
||||||
ProjectType.UNKNOWN: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_backend_paths(pkg: str) -> list[str]:
|
def _expected_backend_paths(pkg: str, no_db: bool = False) -> list[str]:
|
||||||
"""Return the nested ``src/<package>/`` structure for the backend type.
|
"""Return the nested ``src/<package>/`` structure for the backend type.
|
||||||
|
|
||||||
``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``).
|
``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``).
|
||||||
|
When ``no_db`` is true (``[tool.project-status] no_db = true`` in
|
||||||
|
pyproject.toml), ``db/models`` is excluded from the expected paths —
|
||||||
|
cookiecutter ``use_db=no`` removes the ``db/`` dir.
|
||||||
"""
|
"""
|
||||||
return [
|
paths = [
|
||||||
f"src/{pkg}/api/v1",
|
f"src/{pkg}/api/v1",
|
||||||
f"src/{pkg}/db/models",
|
f"src/{pkg}/db/models",
|
||||||
f"src/{pkg}/schemas",
|
f"src/{pkg}/schemas",
|
||||||
|
|
@ -328,6 +331,9 @@ def _expected_backend_paths(pkg: str) -> list[str]:
|
||||||
f"src/{pkg}/config/settings.py",
|
f"src/{pkg}/config/settings.py",
|
||||||
"main.py",
|
"main.py",
|
||||||
]
|
]
|
||||||
|
if no_db:
|
||||||
|
paths.pop(1) # remove ``src/<pkg>/db/models``
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
# ── README delimiter tags (12) — ported from create-readme.ts:140-199 ───────
|
# ── README delimiter tags (12) — ported from create-readme.ts:140-199 ───────
|
||||||
|
|
@ -533,16 +539,65 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
|
||||||
|
"""Fullstack frontend stack detection: tailwindcss + bits-ui in
|
||||||
|
``frontend/package.json`` deps + ``components.json`` + ``tsconfig.json``
|
||||||
|
existence (4 markers).
|
||||||
|
|
||||||
|
Returns ``None`` if ``frontend/package.json`` does not exist (caller
|
||||||
|
surfaces the missing-package.json WARN separately). Returns a WARN
|
||||||
|
``CheckResult`` listing the missing markers when any are absent. Returns
|
||||||
|
``None`` (no CheckResult) when all 4 markers are present — the caller
|
||||||
|
emits an OK in the structure loop is not needed; we return ``None`` so
|
||||||
|
nothing extra is appended, keeping the structure group clean.
|
||||||
|
|
||||||
|
Actually: returns an OK ``CheckResult`` when all markers present, so the
|
||||||
|
audit explicitly confirms the frontend stack is up-to-date.
|
||||||
|
"""
|
||||||
|
pkg_path = ctx.root / "frontend" / "package.json"
|
||||||
|
if not pkg_path.exists():
|
||||||
|
return None # surfaced by the existing ``frontend/package.json`` check
|
||||||
|
required_deps = project_contract.FRONTEND_STACK_MARKERS["fullstack_package_deps"]
|
||||||
|
required_files = project_contract.FRONTEND_STACK_MARKERS["fullstack_files"]
|
||||||
|
missing: list[str] = []
|
||||||
|
try:
|
||||||
|
pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
missing.extend(f"{d} in package.json" for d in required_deps)
|
||||||
|
pkg = {}
|
||||||
|
if not missing:
|
||||||
|
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||||
|
for dep in required_deps:
|
||||||
|
if dep not in deps:
|
||||||
|
missing.append(dep)
|
||||||
|
for rel in required_files:
|
||||||
|
if not (ctx.root / rel).exists():
|
||||||
|
missing.append(rel)
|
||||||
|
if missing:
|
||||||
|
return CheckResult(
|
||||||
|
CheckStatus.WARN,
|
||||||
|
"frontend stack",
|
||||||
|
"frontend stack outdated: missing "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
+ ". Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS.",
|
||||||
|
)
|
||||||
|
return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected")
|
||||||
|
|
||||||
|
|
||||||
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
|
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
|
||||||
"""Type-specific extra checks beyond the expected dirs list."""
|
"""Type-specific extra checks beyond the expected dirs list."""
|
||||||
results: list[CheckResult] = []
|
results: list[CheckResult] = []
|
||||||
if ptype == ProjectType.BACKEND:
|
if ptype == ProjectType.BACKEND:
|
||||||
results.append(_check_backend_lifespan(ctx))
|
results.append(_check_backend_lifespan(ctx))
|
||||||
elif ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx):
|
if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx):
|
||||||
results.append(
|
results.append(
|
||||||
CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")
|
CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")
|
||||||
)
|
)
|
||||||
elif ptype == ProjectType.CLI:
|
if ptype == ProjectType.FULLSTACK:
|
||||||
|
frontend_stack = _check_frontend_stack(ctx)
|
||||||
|
if frontend_stack is not None:
|
||||||
|
results.append(frontend_stack)
|
||||||
|
if ptype == ProjectType.CLI:
|
||||||
results.append(_check_cli_package(ctx))
|
results.append(_check_cli_package(ctx))
|
||||||
flat = _check_flat_layout(ptype, ctx)
|
flat = _check_flat_layout(ptype, ctx)
|
||||||
if flat is not None:
|
if flat is not None:
|
||||||
|
|
@ -565,9 +620,10 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return group
|
return group
|
||||||
expected = _expected_backend_paths(pkg)
|
no_db = bool(ctx.config.get("no_db", False))
|
||||||
|
expected = _expected_backend_paths(pkg, no_db=no_db)
|
||||||
else:
|
else:
|
||||||
expected = STRUCTURE_EXPECTED.get(ptype, [])
|
expected = STRUCTURE_EXPECTED.get(ptype.value, [])
|
||||||
if not expected:
|
if not expected:
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
||||||
|
|
|
||||||
87
.opencode/scripts/project_contract.py
Normal file
87
.opencode/scripts/project_contract.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Project contract: single source of truth for project types, stacks,
|
||||||
|
expected structure, frontend markers and no_db support.
|
||||||
|
|
||||||
|
Imported by both ``spec-status.py`` and ``project-status.py`` via
|
||||||
|
``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation).
|
||||||
|
Stdlib-only imports.
|
||||||
|
|
||||||
|
Contract symbols:
|
||||||
|
ProjectType — StrEnum with 7 members (incl. MCP_SERVER + UNKNOWN)
|
||||||
|
VALID_TYPES — set[str] excluding "unknown"
|
||||||
|
STACK_REQUIRED — dict[type -> list[str]] of mandatory stack items
|
||||||
|
STRUCTURE_EXPECTED — dict[type -> list[str]] of expected top-level dirs/files
|
||||||
|
FRONTEND_STACK_MARKERS — dict with keys for fullstack frontend detection
|
||||||
|
NO_DB_SUPPORTED — set[str] of types that support no_db=true
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectType(StrEnum):
|
||||||
|
"""Project type enum (auto-detected or spec-declared)."""
|
||||||
|
|
||||||
|
FULLSTACK = "fullstack"
|
||||||
|
BACKEND = "backend"
|
||||||
|
CLI = "cli"
|
||||||
|
BOT = "bot"
|
||||||
|
WORKER = "worker"
|
||||||
|
MCP_SERVER = "mcp-server"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Excludes "unknown" — used by spec-status PROJECT_TYPE phase validation.
|
||||||
|
VALID_TYPES: set[str] = {t.value for t in ProjectType if t != ProjectType.UNKNOWN}
|
||||||
|
|
||||||
|
# Mandatory stack items per project type (spec-status Phase 2 STACK).
|
||||||
|
# `uv` stays in ALL stacks (build-tool, mentioned in stack.md).
|
||||||
|
STACK_REQUIRED: dict[str, list[str]] = {
|
||||||
|
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy", "loguru", "pydantic"],
|
||||||
|
"fullstack": [
|
||||||
|
"fastapi",
|
||||||
|
"tortoise",
|
||||||
|
"svelte",
|
||||||
|
"sveltekit",
|
||||||
|
"biome",
|
||||||
|
"uv",
|
||||||
|
"ruff",
|
||||||
|
"mypy",
|
||||||
|
"pytest",
|
||||||
|
"tailwind",
|
||||||
|
"shadcn",
|
||||||
|
"typescript",
|
||||||
|
],
|
||||||
|
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
||||||
|
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
||||||
|
"bot": ["aiogram", "fastapi", "uv", "ruff", "mypy", "pytest"],
|
||||||
|
"worker": ["prefect", "uv", "ruff", "mypy", "pytest"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expected top-level structure per type (project-status check_structure).
|
||||||
|
# BACKEND is resolved dynamically by ``_expected_backend_paths`` (src/<pkg>/...),
|
||||||
|
# so it is NOT in this dict. mcp-server structure is TBD out of scope.
|
||||||
|
# Keys are strings (project type values), NOT ProjectType enum members —
|
||||||
|
# kept as plain strings for portability across both oracles.
|
||||||
|
STRUCTURE_EXPECTED: dict[str, list[str]] = {
|
||||||
|
"fullstack": ["backend", "frontend"],
|
||||||
|
"cli": ["src"], # src/<package>/ — checked generically
|
||||||
|
"bot": ["src/bot.py"],
|
||||||
|
"worker": ["src/flow.py"],
|
||||||
|
"unknown": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fullstack frontend stack markers (project-status _check_type_specific_structure).
|
||||||
|
# `tailwindcss` + `bits-ui` (shadcn-svelte proxy) in package.json deps, plus
|
||||||
|
# `components.json` (shadcn config) and `tsconfig.json` (TypeScript) existence.
|
||||||
|
FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
|
||||||
|
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
||||||
|
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Project types that support ``[tool.project-status] no_db = true`` in
|
||||||
|
# pyproject.toml (skip ``db/models`` check). Backend is the primary case;
|
||||||
|
# fullstack is included for completeness (latent — _check_scattered_models
|
||||||
|
# already early-returns when db/models absent).
|
||||||
|
NO_DB_SUPPORTED: set[str] = {"backend", "fullstack"}
|
||||||
|
|
@ -37,6 +37,7 @@ Nine phases:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import importlib.util
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -44,6 +45,12 @@ from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
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:
|
def _resolve_repo_root() -> Path:
|
||||||
"""Resolve repo root via git (cwd-aware), fallback to script location."""
|
"""Resolve repo root via git (cwd-aware), fallback to script location."""
|
||||||
|
|
@ -67,29 +74,10 @@ PHASE_FILES: dict[int, str] = {
|
||||||
6: "roadmap.md",
|
6: "roadmap.md",
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
|
# Re-exported from project_contract.py for backward compatibility
|
||||||
|
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
|
||||||
STACK_REQUIRED: dict[str, list[str]] = {
|
VALID_TYPES = project_contract.VALID_TYPES
|
||||||
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy", "loguru", "pydantic"],
|
STACK_REQUIRED = project_contract.STACK_REQUIRED
|
||||||
"fullstack": [
|
|
||||||
"fastapi",
|
|
||||||
"tortoise",
|
|
||||||
"svelte",
|
|
||||||
"sveltekit",
|
|
||||||
"biome",
|
|
||||||
"uv",
|
|
||||||
"ruff",
|
|
||||||
"mypy",
|
|
||||||
"pytest",
|
|
||||||
"tailwind",
|
|
||||||
"shadcn",
|
|
||||||
"typescript",
|
|
||||||
],
|
|
||||||
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
|
||||||
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
|
||||||
"bot": ["aiogram", "fastapi", "uv", "ruff", "mypy", "pytest"],
|
|
||||||
"worker": ["prefect", "uv", "ruff", "mypy", "pytest"],
|
|
||||||
}
|
|
||||||
|
|
||||||
PHASE_NAMES = [
|
PHASE_NAMES = [
|
||||||
"DETECT",
|
"DETECT",
|
||||||
|
|
@ -254,8 +242,13 @@ def check_stack() -> PhaseResult:
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
||||||
required = STACK_REQUIRED[ptype]
|
required = STACK_REQUIRED[ptype]
|
||||||
stack_body = stack_file.read_text()
|
stack_body = stack_file.read_text()
|
||||||
stack_lower = stack_body.lower()
|
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind``
|
||||||
missing = [item for item in required if item not in stack_lower]
|
# 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:
|
if missing:
|
||||||
return PhaseResult(
|
return PhaseResult(
|
||||||
PhaseStatus.NOT_DONE,
|
PhaseStatus.NOT_DONE,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,13 @@ def main() -> None:
|
||||||
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||||
_remove(f"src/{pkg}/services/auth_service.py")
|
_remove(f"src/{pkg}/services/auth_service.py")
|
||||||
_remove(f"tests/test_auth.py")
|
_remove(f"tests/test_auth.py")
|
||||||
|
# Write ``no_db = true`` into the existing ``[tool.project-status]``
|
||||||
|
# section so the project-status oracle skips the ``db/models``
|
||||||
|
# structure check for this no-db project (issue #266: no_db polarity
|
||||||
|
# fix). Append the key to the section at the end of the file.
|
||||||
|
pyproject = PROJECT_DIR / "pyproject.toml"
|
||||||
|
with open(pyproject, "a") as f:
|
||||||
|
f.write("\nno_db = true\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ def main() -> None:
|
||||||
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||||
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||||
_remove(f"backend/tests/test_auth.py")
|
_remove(f"backend/tests/test_auth.py")
|
||||||
|
# Write ``no_db = true`` into the existing ``[tool.project-status]``
|
||||||
|
# section so the project-status oracle skips the ``db/models``
|
||||||
|
# structure check for this no-db project (issue #266: no_db polarity
|
||||||
|
# fix). Append the key to the section at the end of the file.
|
||||||
|
pyproject = PROJECT_DIR / "backend" / "pyproject.toml"
|
||||||
|
with open(pyproject, "a") as f:
|
||||||
|
f.write("\nno_db = true\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -984,6 +984,42 @@ def test_cookiecutter_json_default_is_valid_identifier(template_name):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── issue #266: cookiecutter use_db=no writes no_db marker ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"template_name, extra_context",
|
||||||
|
[("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"})],
|
||||||
|
)
|
||||||
|
def test_backend_use_db_no_has_no_db_in_pyproject(render):
|
||||||
|
"""Backend rendered with ``use_db=no`` contains ``no_db = true`` in
|
||||||
|
``[tool.project-status]`` section of ``pyproject.toml`` (issue #266).
|
||||||
|
|
||||||
|
The post-gen hook appends ``no_db = true`` to the existing
|
||||||
|
``[tool.project-status]`` section so the project-status oracle skips
|
||||||
|
the ``db/models`` structure check.
|
||||||
|
"""
|
||||||
|
pyproject = (render / "pyproject.toml").read_text()
|
||||||
|
assert "[tool.project-status]" in pyproject
|
||||||
|
assert "no_db = true" in pyproject, (
|
||||||
|
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"template_name, extra_context",
|
||||||
|
[("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"})],
|
||||||
|
)
|
||||||
|
def test_fullstack_use_db_no_has_no_db_in_pyproject(render):
|
||||||
|
"""Fullstack rendered with ``use_db=no`` contains ``no_db = true`` in
|
||||||
|
``[tool.project-status]`` section of ``backend/pyproject.toml``."""
|
||||||
|
pyproject = (render / "backend" / "pyproject.toml").read_text()
|
||||||
|
assert "[tool.project-status]" in pyproject
|
||||||
|
assert "no_db = true" in pyproject, (
|
||||||
|
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"template_name, extra_context",
|
"template_name, extra_context",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
140
tests/test_project_contract.py
Normal file
140
tests/test_project_contract.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Tests for ``.opencode/scripts/project_contract.py`` — single source of
|
||||||
|
truth for project types, stacks, structure, frontend markers, no_db.
|
||||||
|
|
||||||
|
The contract module is imported via ``importlib.util`` (no ``sys.path``
|
||||||
|
mutation), mirroring how ``spec-status.py`` and ``project-status.py`` load it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project_contract.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("project_contract", SCRIPT_PATH)
|
||||||
|
pc = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||||||
|
_spec.loader.exec_module(pc) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
# ── ProjectType enum ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_type_enum_values():
|
||||||
|
"""ProjectType has exactly 7 members including MCP_SERVER and UNKNOWN."""
|
||||||
|
members = {t.name for t in pc.ProjectType}
|
||||||
|
assert members == {
|
||||||
|
"FULLSTACK",
|
||||||
|
"BACKEND",
|
||||||
|
"CLI",
|
||||||
|
"BOT",
|
||||||
|
"WORKER",
|
||||||
|
"MCP_SERVER",
|
||||||
|
"UNKNOWN",
|
||||||
|
}, f"unexpected members: {members}"
|
||||||
|
# Values are lowercase strings.
|
||||||
|
values = {t.value for t in pc.ProjectType}
|
||||||
|
assert values == {
|
||||||
|
"fullstack",
|
||||||
|
"backend",
|
||||||
|
"cli",
|
||||||
|
"bot",
|
||||||
|
"worker",
|
||||||
|
"mcp-server",
|
||||||
|
"unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_types_excludes_unknown():
|
||||||
|
"""VALID_TYPES excludes ``"unknown"`` (used by spec-status PROJECT_TYPE)."""
|
||||||
|
assert "unknown" not in pc.VALID_TYPES
|
||||||
|
assert "mcp-server" in pc.VALID_TYPES
|
||||||
|
assert {
|
||||||
|
"fullstack",
|
||||||
|
"backend",
|
||||||
|
"cli",
|
||||||
|
"bot",
|
||||||
|
"worker",
|
||||||
|
"mcp-server",
|
||||||
|
} == pc.VALID_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def test_stack_required_keys_match_valid_types():
|
||||||
|
"""STACK_REQUIRED keys == VALID_TYPES (all 6 types including mcp-server)."""
|
||||||
|
assert set(pc.STACK_REQUIRED.keys()) == pc.VALID_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def test_stack_required_uv_in_all_types():
|
||||||
|
"""``uv`` stays in ALL stacks (build-tool, mentioned in stack.md)."""
|
||||||
|
for ptype, required in pc.STACK_REQUIRED.items():
|
||||||
|
assert "uv" in required, f"missing 'uv' in {ptype} stack: {required}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_structure_expected_keys_subset():
|
||||||
|
"""STRUCTURE_EXPECTED keys ⊆ VALID_TYPES ∪ {"unknown"}.
|
||||||
|
|
||||||
|
BACKEND is resolved dynamically (not in dict); mcp-server TBD out of scope.
|
||||||
|
"""
|
||||||
|
keys = set(pc.STRUCTURE_EXPECTED.keys())
|
||||||
|
valid_plus_unknown = pc.VALID_TYPES | {"unknown"}
|
||||||
|
assert keys.issubset(valid_plus_unknown), f"unexpected keys: {keys - valid_plus_unknown}"
|
||||||
|
# Backend NOT in dict (resolved dynamically via _expected_backend_paths)
|
||||||
|
assert "backend" not in keys
|
||||||
|
# mcp-server NOT in dict (out of scope)
|
||||||
|
assert "mcp-server" not in keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_db_supported_subset():
|
||||||
|
"""NO_DB_SUPPORTED ⊆ VALID_TYPES (backend + fullstack only)."""
|
||||||
|
assert pc.NO_DB_SUPPORTED.issubset(pc.VALID_TYPES)
|
||||||
|
assert {"backend", "fullstack"} == pc.NO_DB_SUPPORTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_stack_markers_keys():
|
||||||
|
"""FRONTEND_STACK_MARKERS has the expected keys with correct marker lists."""
|
||||||
|
assert set(pc.FRONTEND_STACK_MARKERS.keys()) == {
|
||||||
|
"fullstack_package_deps",
|
||||||
|
"fullstack_files",
|
||||||
|
}
|
||||||
|
assert pc.FRONTEND_STACK_MARKERS["fullstack_package_deps"] == [
|
||||||
|
"tailwindcss",
|
||||||
|
"bits-ui",
|
||||||
|
]
|
||||||
|
assert pc.FRONTEND_STACK_MARKERS["fullstack_files"] == [
|
||||||
|
"frontend/components.json",
|
||||||
|
"frontend/tsconfig.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── re-export sanity (backward compat) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_status_reexports_match_contract():
|
||||||
|
"""``spec-status.py`` re-exports VALID_TYPES / STACK_REQUIRED from contract."""
|
||||||
|
spec_status_path = (
|
||||||
|
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "spec-status.py"
|
||||||
|
)
|
||||||
|
ss_spec = importlib.util.spec_from_file_location("spec_status", spec_status_path)
|
||||||
|
ss = importlib.util.module_from_spec(ss_spec) # type: ignore[arg-type]
|
||||||
|
sys.modules["spec_status"] = ss
|
||||||
|
ss_spec.loader.exec_module(ss) # type: ignore[union-attr]
|
||||||
|
assert ss.VALID_TYPES is pc.VALID_TYPES or ss.VALID_TYPES == pc.VALID_TYPES
|
||||||
|
assert ss.STACK_REQUIRED == pc.STACK_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_status_reexports_match_contract():
|
||||||
|
"""``project-status.py`` re-exports ProjectType / STRUCTURE_EXPECTED."""
|
||||||
|
project_status_path = (
|
||||||
|
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py"
|
||||||
|
)
|
||||||
|
ps_spec = importlib.util.spec_from_file_location("project_status", project_status_path)
|
||||||
|
ps = importlib.util.module_from_spec(ps_spec) # type: ignore[arg-type]
|
||||||
|
sys.modules["project_status"] = ps
|
||||||
|
ps_spec.loader.exec_module(ps) # type: ignore[union-attr]
|
||||||
|
# Note: ProjectType is loaded twice (once here, once inside project-status
|
||||||
|
# via importlib.util with a different module name) so identity differs,
|
||||||
|
# but the enum members compare equal by value.
|
||||||
|
assert list(ps.ProjectType) == list(pc.ProjectType)
|
||||||
|
assert ps.STRUCTURE_EXPECTED == pc.STRUCTURE_EXPECTED
|
||||||
|
|
@ -10,6 +10,7 @@ Covers: auto-detect (5 types + unknown), 7 check groups, format output
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -236,7 +237,15 @@ def _make_fullstack_repo(tmp_path: Path) -> None:
|
||||||
"@router.get('/users')\nasync def list_users():\n return []\n"
|
"@router.get('/users')\nasync def list_users():\n return []\n"
|
||||||
)
|
)
|
||||||
_write_pyproject(tmp_path / "backend", deps=["fastapi", "uvicorn"], cov_source=["src"])
|
_write_pyproject(tmp_path / "backend", deps=["fastapi", "uvicorn"], cov_source=["src"])
|
||||||
(tmp_path / "frontend" / "package.json").write_text('{"name": "test-frontend"}\n')
|
# Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
|
||||||
|
# (issue #266): package.json deps + components.json + tsconfig.json are the
|
||||||
|
# 4 frontend stack markers checked by ``_check_frontend_stack``.
|
||||||
|
(tmp_path / "frontend" / "package.json").write_text(
|
||||||
|
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
|
||||||
|
'"bits-ui": "^1.0.0"}}\n'
|
||||||
|
)
|
||||||
|
(tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
|
||||||
|
(tmp_path / "frontend" / "components.json").write_text("{}\n")
|
||||||
|
|
||||||
|
|
||||||
# ── parse_remote_url ─────────────────────────────────────────────────────────
|
# ── parse_remote_url ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -290,6 +299,19 @@ def test_load_config_defaults_when_no_pyproject(tmp_path):
|
||||||
cfg = ps.load_config()
|
cfg = ps.load_config()
|
||||||
assert cfg["route_line_limit"] == 50
|
assert cfg["route_line_limit"] == 50
|
||||||
assert cfg["min_test_count"] == 1
|
assert cfg["min_test_count"] == 1
|
||||||
|
assert cfg["no_db"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_reads_no_db(tmp_path):
|
||||||
|
"""``[tool.project-status] no_db = true`` is parsed into config (issue #266)."""
|
||||||
|
monkeypatch = pytest.MonkeyPatch()
|
||||||
|
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
||||||
|
(tmp_path / "pyproject.toml").write_text(
|
||||||
|
"[tool.project-status]\nroute_line_limit = 80\nno_db = true\n"
|
||||||
|
)
|
||||||
|
cfg = ps.load_config()
|
||||||
|
assert cfg["no_db"] is True
|
||||||
|
assert cfg["route_line_limit"] == 80
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_reads_section(tmp_path):
|
def test_load_config_reads_section(tmp_path):
|
||||||
|
|
@ -2142,3 +2164,102 @@ def test_scattered_models_no_pyproject_skip(tmp_path, ctx):
|
||||||
)
|
)
|
||||||
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
|
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
|
||||||
assert results == [], f"no pyproject should skip, got: {results}"
|
assert results == [], f"no pyproject should skip, got: {results}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── issue #266: frontend stack detection (fullstack) ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullstack_frontend_stack_ok(tmp_path, ctx):
|
||||||
|
"""All 4 frontend markers present (tailwindcss + bits-ui + components.json
|
||||||
|
+ tsconfig.json) → OK."""
|
||||||
|
_make_fullstack_repo(tmp_path)
|
||||||
|
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||||
|
frontend_checks = [c for c in group.checks if c.name == "frontend stack"]
|
||||||
|
assert frontend_checks, f"expected 'frontend stack' check, got: {group.checks}"
|
||||||
|
assert frontend_checks[0].status == ps.CheckStatus.OK, (
|
||||||
|
f"expected OK, got {frontend_checks[0].status}: {frontend_checks[0].detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullstack_frontend_stack_missing_tailwind(tmp_path, ctx):
|
||||||
|
"""package.json without ``tailwindcss`` dep → WARN."""
|
||||||
|
_make_fullstack_repo(tmp_path)
|
||||||
|
pkg = tmp_path / "frontend" / "package.json"
|
||||||
|
pkg.write_text('{"name": "test-frontend", "dependencies": {"bits-ui": "^1.0.0"}}\n')
|
||||||
|
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||||
|
frontend = [c for c in group.checks if c.name == "frontend stack"]
|
||||||
|
assert frontend and frontend[0].status == ps.CheckStatus.WARN
|
||||||
|
assert "tailwindcss" in frontend[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullstack_frontend_stack_missing_shadcn(tmp_path, ctx):
|
||||||
|
"""package.json without ``bits-ui`` dep (shadcn-svelte proxy) → WARN."""
|
||||||
|
_make_fullstack_repo(tmp_path)
|
||||||
|
pkg = tmp_path / "frontend" / "package.json"
|
||||||
|
pkg.write_text('{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0"}}\n')
|
||||||
|
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||||
|
frontend = [c for c in group.checks if c.name == "frontend stack"]
|
||||||
|
assert frontend and frontend[0].status == ps.CheckStatus.WARN
|
||||||
|
assert "bits-ui" in frontend[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullstack_frontend_stack_missing_components_json(tmp_path, ctx):
|
||||||
|
"""Missing ``frontend/components.json`` → WARN."""
|
||||||
|
_make_fullstack_repo(tmp_path)
|
||||||
|
(tmp_path / "frontend" / "components.json").unlink()
|
||||||
|
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||||
|
frontend = [c for c in group.checks if c.name == "frontend stack"]
|
||||||
|
assert frontend and frontend[0].status == ps.CheckStatus.WARN
|
||||||
|
assert "components.json" in frontend[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
|
||||||
|
"""Missing ``frontend/tsconfig.json`` → WARN."""
|
||||||
|
_make_fullstack_repo(tmp_path)
|
||||||
|
(tmp_path / "frontend" / "tsconfig.json").unlink()
|
||||||
|
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||||
|
frontend = [c for c in group.checks if c.name == "frontend stack"]
|
||||||
|
assert frontend and frontend[0].status == ps.CheckStatus.WARN
|
||||||
|
assert "tsconfig.json" in frontend[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
# ── issue #266: no_db polarity fix (backend) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_no_db_skips_db_models(tmp_path, ctx):
|
||||||
|
"""Backend with ``[tool.project-status] no_db = true`` in pyproject.toml
|
||||||
|
→ ``db/models`` is NOT in the expected paths (no FAIL when absent)."""
|
||||||
|
_make_backend_repo(tmp_path)
|
||||||
|
# Remove the db/models dir (simulating cookiecutter use_db=no)
|
||||||
|
shutil.rmtree(tmp_path / "src" / "test_repo" / "db")
|
||||||
|
# Write a [tool.project-status] section with no_db=true into pyproject.toml
|
||||||
|
# (cookiecutter post_gen_project.py appends this to the existing section).
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(pyproject.read_text() + "\n[tool.project-status]\nno_db = true\n")
|
||||||
|
# Reload config so no_db is picked up
|
||||||
|
ctx_fresh = ps.RepoCtx(root=tmp_path, config=ps.load_config(tmp_path))
|
||||||
|
assert ctx_fresh.config["no_db"] is True, f"no_db not parsed: {ctx_fresh.config}"
|
||||||
|
group = ps.check_structure(ps.ProjectType.BACKEND, ctx_fresh)
|
||||||
|
# db/models should NOT be among expected checks (skipped by no_db)
|
||||||
|
db_models_checks = [c for c in group.checks if "db/models" in c.name]
|
||||||
|
assert not db_models_checks, (
|
||||||
|
f"db/models should be skipped with no_db=true, got: {db_models_checks}"
|
||||||
|
)
|
||||||
|
# And overall should not FAIL on db/models
|
||||||
|
assert group.overall() != ps.CheckStatus.FAIL or not any(
|
||||||
|
"db/models" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks
|
||||||
|
), f"db/models FAIL despite no_db=true: {group.checks}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_with_db_requires_db_models(tmp_path, ctx):
|
||||||
|
"""Backend WITHOUT no_db marker → ``db/models`` required (FAIL if absent)."""
|
||||||
|
_make_backend_repo(tmp_path)
|
||||||
|
# Remove the db/models dir
|
||||||
|
shutil.rmtree(tmp_path / "src" / "test_repo" / "db")
|
||||||
|
# No no_db marker in pyproject — default config has no_db=False
|
||||||
|
assert ctx.config.get("no_db", False) is False
|
||||||
|
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
|
||||||
|
db_models_fail = [
|
||||||
|
c for c in group.checks if "db/models" in c.name and c.status == ps.CheckStatus.FAIL
|
||||||
|
]
|
||||||
|
assert db_models_fail, f"expected db/models FAIL without no_db marker, got: {group.checks}"
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,26 @@ def test_check_stack_case_insensitive(monkeypatch, tmp_path):
|
||||||
assert result.status == ss.PhaseStatus.DONE
|
assert result.status == ss.PhaseStatus.DONE
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_stack_word_boundary_uv_not_in_uvicorn(monkeypatch, tmp_path):
|
||||||
|
"""Word-boundary regex: ``uv`` does NOT match ``uvicorn`` (issue #266).
|
||||||
|
|
||||||
|
stack.md contains ``uvicorn`` but no standalone ``uv`` → NOT_DONE with
|
||||||
|
``uv`` in the missing list. Previously the substring match (``"uv" in
|
||||||
|
"uvicorn"``) gave a false positive.
|
||||||
|
"""
|
||||||
|
spec_dir = _set_spec_dir(monkeypatch, tmp_path)
|
||||||
|
_write_meta(spec_dir, project="foo", type="backend")
|
||||||
|
# ``uvicorn`` contains the substring ``uv`` but \buv\b does not match it.
|
||||||
|
(spec_dir / "stack.md").write_text(
|
||||||
|
"- fastapi\n- tortoise\n- uvicorn\n- pytest\n- ruff\n- mypy\n- loguru\n- pydantic\n"
|
||||||
|
)
|
||||||
|
result = ss.check_stack()
|
||||||
|
assert result.status == ss.PhaseStatus.NOT_DONE, (
|
||||||
|
f"uvicorn should NOT satisfy 'uv' (word-boundary), got: {result.detail}"
|
||||||
|
)
|
||||||
|
assert "uv" in result.detail
|
||||||
|
|
||||||
|
|
||||||
# ── check_modules (Phase 3) ──────────────────────────────────────────────────
|
# ── check_modules (Phase 3) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue