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:
Sergey 2026-08-04 23:20:11 +03:00 committed by GitHub
parent 7c3ded601f
commit c0e8ec816a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 518 additions and 51 deletions

View file

@ -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}: нет контракта")

View 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"}

View file

@ -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,

View file

@ -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__":

View file

@ -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__":

View file

@ -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",
[ [

View 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

View file

@ -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}"

View file

@ -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) ──────────────────────────────────────────────────