diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index 1eef4a9..a6b2a12 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -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//...`` where ```` comes from - # ``[project].name`` normalized (``my-project`` → ``my_project``). - ProjectType.FULLSTACK: ["backend", "frontend"], - ProjectType.CLI: ["src"], # src// — 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//`` 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//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}: нет контракта") diff --git a/.opencode/scripts/project_contract.py b/.opencode/scripts/project_contract.py new file mode 100644 index 0000000..9df4a30 --- /dev/null +++ b/.opencode/scripts/project_contract.py @@ -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//...), +# 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// — 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"} diff --git a/.opencode/scripts/spec-status.py b/.opencode/scripts/spec-status.py index 723955e..70c7e94 100644 --- a/.opencode/scripts/spec-status.py +++ b/.opencode/scripts/spec-status.py @@ -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, diff --git a/.opencode/templates/backend/hooks/post_gen_project.py b/.opencode/templates/backend/hooks/post_gen_project.py index 2422648..4706e84 100644 --- a/.opencode/templates/backend/hooks/post_gen_project.py +++ b/.opencode/templates/backend/hooks/post_gen_project.py @@ -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__": diff --git a/.opencode/templates/fullstack/hooks/post_gen_project.py b/.opencode/templates/fullstack/hooks/post_gen_project.py index b0a0d87..c25b5b5 100644 --- a/.opencode/templates/fullstack/hooks/post_gen_project.py +++ b/.opencode/templates/fullstack/hooks/post_gen_project.py @@ -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__": diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index d8d20e5..73d2027 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -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", [ diff --git a/tests/test_project_contract.py b/tests/test_project_contract.py new file mode 100644 index 0000000..c920932 --- /dev/null +++ b/tests/test_project_contract.py @@ -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 diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 2f91afe..bab9462 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -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}" diff --git a/tests/test_spec_status.py b/tests/test_spec_status.py index 3754cfc..10d4f9b 100644 --- a/tests/test_spec_status.py +++ b/tests/test_spec_status.py @@ -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) ──────────────────────────────────────────────────