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 ast
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -40,6 +42,12 @@ from enum import StrEnum
|
|||
from pathlib import Path
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -67,6 +75,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||
"route_line_limit": 50,
|
||||
"min_test_count": 1,
|
||||
"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.
|
||||
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
|
||||
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||
|
|
@ -109,15 +122,9 @@ class CheckStatus(StrEnum):
|
|||
FAIL = "FAIL"
|
||||
|
||||
|
||||
class ProjectType(StrEnum):
|
||||
"""Auto-detected project type."""
|
||||
|
||||
FULLSTACK = "fullstack"
|
||||
BACKEND = "backend"
|
||||
CLI = "cli"
|
||||
BOT = "bot"
|
||||
WORKER = "worker"
|
||||
UNKNOWN = "unknown"
|
||||
# Re-exported from project_contract.py for backward compatibility
|
||||
# (tests use ``ps.ProjectType.X``).
|
||||
ProjectType = project_contract.ProjectType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -303,24 +310,20 @@ def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType:
|
|||
# ── expected structure per type ──────────────────────────────────────────────
|
||||
|
||||
|
||||
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
|
||||
# BACKEND is resolved dynamically by ``_expected_backend_paths`` — the
|
||||
# paths are ``src/<package_name>/...`` where ``<package_name>`` comes from
|
||||
# ``[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: [],
|
||||
}
|
||||
# Re-exported from project_contract.py for backward compatibility
|
||||
# (tests use ``ps.STRUCTURE_EXPECTED``).
|
||||
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED
|
||||
|
||||
|
||||
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.
|
||||
|
||||
``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}/db/models",
|
||||
f"src/{pkg}/schemas",
|
||||
|
|
@ -328,6 +331,9 @@ def _expected_backend_paths(pkg: str) -> list[str]:
|
|||
f"src/{pkg}/config/settings.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 ───────
|
||||
|
|
@ -533,16 +539,65 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
|
|||
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]:
|
||||
"""Type-specific extra checks beyond the expected dirs list."""
|
||||
results: list[CheckResult] = []
|
||||
if ptype == ProjectType.BACKEND:
|
||||
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(
|
||||
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))
|
||||
flat = _check_flat_layout(ptype, ctx)
|
||||
if flat is not None:
|
||||
|
|
@ -565,9 +620,10 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
|||
)
|
||||
)
|
||||
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:
|
||||
expected = STRUCTURE_EXPECTED.get(ptype, [])
|
||||
expected = STRUCTURE_EXPECTED.get(ptype.value, [])
|
||||
if not expected:
|
||||
group.checks.append(
|
||||
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
|
||||
|
||||
import functools
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -44,6 +45,12 @@ 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."""
|
||||
|
|
@ -67,29 +74,10 @@ PHASE_FILES: dict[int, str] = {
|
|||
6: "roadmap.md",
|
||||
}
|
||||
|
||||
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
|
||||
|
||||
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"],
|
||||
}
|
||||
# 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",
|
||||
|
|
@ -254,8 +242,13 @@ def check_stack() -> PhaseResult:
|
|||
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
||||
required = STACK_REQUIRED[ptype]
|
||||
stack_body = stack_file.read_text()
|
||||
stack_lower = stack_body.lower()
|
||||
missing = [item for item in required if item not in stack_lower]
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ def main() -> None:
|
|||
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"src/{pkg}/services/auth_service.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__":
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ def main() -> None:
|
|||
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"backend/src/{pkg}/services/auth_service.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__":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"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 shutil
|
||||
import sys
|
||||
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"
|
||||
)
|
||||
_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 ─────────────────────────────────────────────────────────
|
||||
|
|
@ -290,6 +299,19 @@ def test_load_config_defaults_when_no_pyproject(tmp_path):
|
|||
cfg = ps.load_config()
|
||||
assert cfg["route_line_limit"] == 50
|
||||
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):
|
||||
|
|
@ -2142,3 +2164,102 @@ def test_scattered_models_no_pyproject_skip(tmp_path, ctx):
|
|||
)
|
||||
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
|
||||
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
|
||||
|
||||
|
||||
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) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue