Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08385c9dd | ||
|
|
7e4dfcc38b | ||
|
|
d16c2434a3 | ||
|
|
ab0c3dd68d | ||
|
|
9dabc57d9a | ||
|
|
0fbf1d97bc | ||
|
|
93e7843438 | ||
|
|
77143ac6da | ||
|
|
9459e47567 | ||
|
|
6bb115e706 |
31 changed files with 683 additions and 46 deletions
|
|
@ -18,6 +18,10 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
|
|||
# GitHub
|
||||
GITHUB_TOKEN=your-github-token-here
|
||||
|
||||
# Forgejo (self-hosted) — used by status oracles with Forgejo backend
|
||||
FORGEJO_URL=https://git.slaid098.dev
|
||||
FORGEJO_TOKEN=your-forgejo-api-token-here
|
||||
|
||||
# Context7 MCP
|
||||
CONTEXT7_API_KEY=your-context7-api-key-here
|
||||
|
||||
|
|
|
|||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
|
@ -35,10 +35,6 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev
|
||||
- run: uv run ruff check src/ tests/ .opencode/scripts/
|
||||
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
||||
|
|
@ -50,10 +46,6 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev
|
||||
- run: uv run mypy src/
|
||||
|
||||
|
|
@ -65,23 +57,15 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ["3.12", "3.13", "3.14"]
|
||||
python: ["3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
- run: npm ci
|
||||
working-directory: .opencode
|
||||
- run: npm ci
|
||||
working-directory: .opencode/draw-image
|
||||
- run: npm test
|
||||
working-directory: .opencode/draw-image
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
||||
- run: uv run --python ${{ matrix.python }} pytest
|
||||
|
||||
|
|
@ -92,9 +76,5 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev
|
||||
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import re
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
|
@ -85,10 +87,135 @@ class PhaseResult:
|
|||
detail: str
|
||||
|
||||
|
||||
def _forgejo_request(method: str, path: str, body: dict | None = None) -> tuple[int, str, str]:
|
||||
"""Forgejo REST API call. Returns (status_code, body_text, error).
|
||||
|
||||
On non-2xx returns (status_code, body_text, ""). ``urlopen`` raises
|
||||
HTTPError for non-2xx which carries the body; we surface it so callers
|
||||
can branch on the HTTP status. Network/parse errors return (0, "", err).
|
||||
"""
|
||||
base = os.environ.get("FORGEJO_URL")
|
||||
token = os.environ.get("FORGEJO_TOKEN")
|
||||
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
data = None
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request( # noqa: S310 - base URL is operator-configured
|
||||
f"{base}/api/v1{path}", method=method, headers=headers, data=data
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r: # noqa: S310 - base URL is operator-configured
|
||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
||||
except OSError as e:
|
||||
return 0, "", str(e)
|
||||
|
||||
|
||||
def _forgejo_ci_rollup(repo: str, sha: str) -> tuple[int, str, str]:
|
||||
"""Build a GitHub-style statusCheckRollup from the Forgejo commit status."""
|
||||
sc, text, err = _forgejo_request("GET", f"/repos/{repo}/commits/{sha}/status")
|
||||
if sc != 200:
|
||||
return 1, "", err or f"commit status HTTP {sc}"
|
||||
combined = json.loads(text)
|
||||
rollup: list[dict] = []
|
||||
for s in combined.get("statuses", []):
|
||||
st = s.get("status", "").lower()
|
||||
if st == "success":
|
||||
rollup.append({"status": "COMPLETED", "conclusion": "SUCCESS"})
|
||||
elif st == "pending":
|
||||
rollup.append({"status": "IN_PROGRESS", "conclusion": ""})
|
||||
elif st in ("failure", "error"):
|
||||
rollup.append({"status": "COMPLETED", "conclusion": "FAILURE"})
|
||||
else:
|
||||
rollup.append({"status": "QUEUED", "conclusion": ""})
|
||||
return 0, json.dumps({"statusCheckRollup": rollup}), ""
|
||||
|
||||
|
||||
def _forgejo_pr_view(repo: str, n: str, fields: str) -> tuple[int, str, str]: # noqa: PLR0911
|
||||
"""Translate ``gh pr view N --json <fields>`` to Forgejo API calls."""
|
||||
rc, pr_text, err = _forgejo_request("GET", f"/repos/{repo}/pulls/{n}")
|
||||
if rc != 200:
|
||||
return 1, "", err or f"PR API HTTP {rc}"
|
||||
pr = json.loads(pr_text)
|
||||
if fields == "statusCheckRollup":
|
||||
return _forgejo_ci_rollup(repo, pr["head"]["sha"])
|
||||
if fields == "comments":
|
||||
sc, comments_text, ce = _forgejo_request("GET", f"/repos/{repo}/issues/{n}/comments")
|
||||
if sc != 200:
|
||||
return 1, "", ce or f"comments HTTP {sc}"
|
||||
comments = json.loads(comments_text)
|
||||
return 0, json.dumps({"comments": [{"body": c.get("body", "")} for c in comments]}), ""
|
||||
if fields == "state":
|
||||
state = "MERGED" if pr.get("merged") else pr.get("state", "").upper()
|
||||
return 0, json.dumps({"state": state}), ""
|
||||
if fields:
|
||||
return 0, json.dumps({fields: pr.get(fields, "")}), ""
|
||||
return 0, pr_text, ""
|
||||
|
||||
|
||||
def _forgejo_pr_dispatch(repo: str, args: list[str]) -> tuple[int, str, str]:
|
||||
"""Translate ``gh pr ...`` argv to Forgejo API calls."""
|
||||
action = args[1] if len(args) > 1 else ""
|
||||
num = args[2] if len(args) > 2 and args[2].lstrip("-").isdigit() else None
|
||||
if action == "view" and num is not None:
|
||||
fields_idx = args.index("--json") if "--json" in args else -1
|
||||
fields = args[fields_idx + 1] if fields_idx >= 0 else ""
|
||||
return _forgejo_pr_view(repo, num, fields)
|
||||
if action == "list":
|
||||
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/pulls?state=open")
|
||||
if sc != 200:
|
||||
return 1, "", e or f"pr list HTTP {sc}"
|
||||
pulls = json.loads(out_text)
|
||||
return 0, json.dumps([{"number": p["number"]} for p in pulls]), ""
|
||||
return 1, "", f"gh pr argv {args!r} not supported in Forgejo mode"
|
||||
|
||||
|
||||
def _forgejo_gh_dispatch(args: list[str]) -> tuple[int, str, str] | None: # noqa: PLR0911
|
||||
"""Translate a ``gh`` argv to a Forgejo API call. Returns None to defer.
|
||||
|
||||
Returns ``(rc, stdout, stderr)`` shaped like ``run_cmd`` so callers stay
|
||||
unchanged. Returns ``None`` if ``FORGEJO_URL`` is unset (defer to gh) or
|
||||
the argv is not a supported gh subcommand.
|
||||
"""
|
||||
if not os.environ.get("FORGEJO_URL"):
|
||||
return None
|
||||
if not os.environ.get("FORGEJO_TOKEN"):
|
||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
||||
if not args or args[0] != "gh":
|
||||
return None
|
||||
repo_idx = args.index("--repo") if "--repo" in args else -1
|
||||
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
||||
if repo is None:
|
||||
return 1, "", "Forgejo mode requires --repo owner/name"
|
||||
sub = args[1] if len(args) > 1 else ""
|
||||
if sub == "auth" and "status" in args:
|
||||
return 0, "", ""
|
||||
if sub == "pr":
|
||||
return _forgejo_pr_dispatch(repo, args)
|
||||
if sub == "issue" and len(args) > 2 and args[2].lstrip("-").isdigit():
|
||||
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/issues/{args[2]}")
|
||||
if sc == 200:
|
||||
return 0, out_text, ""
|
||||
return 1, "", e or f"issue HTTP {sc}"
|
||||
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
||||
|
||||
|
||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command, return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
"""Run a command, return (returncode, stdout, stderr).
|
||||
|
||||
Forgejo dispatch (ADR-forgejo): when ``FORGEJO_URL`` is set, ``gh`` argv
|
||||
is translated to a Forgejo REST API call instead of spawning ``gh``.
|
||||
GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour — the
|
||||
``gh`` / ``git`` subprocess path is untouched.
|
||||
"""
|
||||
if args and args[0] == "gh":
|
||||
result = _forgejo_gh_dispatch(args)
|
||||
if result is not None:
|
||||
return result
|
||||
proc = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -37,10 +37,13 @@ import argparse
|
|||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
|
@ -187,8 +190,50 @@ class RepoCtx:
|
|||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _forgejo_get(path: str) -> tuple[int, str, str]:
|
||||
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
|
||||
base = os.environ.get("FORGEJO_URL")
|
||||
token = os.environ.get("FORGEJO_TOKEN")
|
||||
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
|
||||
f"{base}/api/v1{path}",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
|
||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
||||
except OSError as e:
|
||||
return 0, "", str(e)
|
||||
|
||||
|
||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command, return (returncode, stdout, stderr). Read-only intent."""
|
||||
"""Run a command, return (returncode, stdout, stderr). Read-only intent.
|
||||
|
||||
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh api
|
||||
repos/<repo>/rules/branches/main`` is routed to the Forgejo
|
||||
``branch_protections/main`` endpoint (different schema, but the
|
||||
downstream string-matching checks ``"pull_request"`` /
|
||||
``"required_status_checks"`` which Forgejo's BranchProtection fields
|
||||
contain). GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour.
|
||||
"""
|
||||
if (
|
||||
args
|
||||
and args[0] == "gh"
|
||||
and os.environ.get("FORGEJO_URL")
|
||||
and len(args) >= 2
|
||||
and args[1] == "api"
|
||||
):
|
||||
if not os.environ.get("FORGEJO_TOKEN"):
|
||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
||||
m = re.match(r"repos/([^/]+/[^/]+)/rules/branches/(\S+)", " ".join(args[2:]))
|
||||
if m:
|
||||
repo, branch = m.group(1), m.group(2)
|
||||
sc, out_text, err = _forgejo_get(f"/repos/{repo}/branch_protections/{branch}")
|
||||
if sc == 200:
|
||||
return 0, out_text, ""
|
||||
return 1, "", err or f"branch protection HTTP {sc}"
|
||||
return 1, "", f"gh api argv {args!r} not supported in Forgejo mode"
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
|
@ -673,6 +718,50 @@ def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
|
|||
return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected")
|
||||
|
||||
|
||||
def _check_mobile_first(ctx: RepoCtx) -> CheckResult | None:
|
||||
"""Fullstack mobile-first guarantee (issue #278): PWA manifest + mobile
|
||||
Playwright + axe-core accessibility.
|
||||
|
||||
Returns ``None`` if ``frontend/package.json`` does not exist (the missing
|
||||
package.json WARN is surfaced separately by ``_check_type_specific_structure``).
|
||||
Returns ``None`` for non-fullstack types — the caller only invokes this for
|
||||
FULLSTACK. Otherwise returns OK when all markers are present, or WARN with
|
||||
the missing marker list (never FAIL — issue #275 all-WARN contract).
|
||||
|
||||
Markers (``MOBILE_FIRST_MARKERS``):
|
||||
* ``frontend/static/manifest.webmanifest`` exists
|
||||
* ``frontend/src/app.html`` contains both "viewport" and "manifest"
|
||||
* ``frontend/tests/e2e/mobile.spec.ts`` exists
|
||||
* ``frontend/tests/e2e/accessibility.spec.ts`` exists
|
||||
* ``@axe-core/playwright`` in ``frontend/package.json`` devDependencies
|
||||
"""
|
||||
pkg_path = ctx.root / "frontend" / "package.json"
|
||||
if not pkg_path.exists():
|
||||
return None
|
||||
missing: list[str] = []
|
||||
for rel in project_contract.MOBILE_FIRST_MARKERS["fullstack_files"]:
|
||||
if not (ctx.root / rel).exists():
|
||||
missing.append(rel)
|
||||
app_html = read_text("frontend/src/app.html", ctx) or ""
|
||||
for marker in project_contract.MOBILE_FIRST_MARKERS["fullstack_app_html_markers"]:
|
||||
if marker not in app_html:
|
||||
missing.append(f"frontend/src/app.html ({marker})")
|
||||
try:
|
||||
pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pkg = {}
|
||||
dev_deps = pkg.get("devDependencies", {}) if isinstance(pkg, dict) else {}
|
||||
if "@axe-core/playwright" not in dev_deps:
|
||||
missing.append("@axe-core/playwright in package.json devDependencies")
|
||||
if missing:
|
||||
return CheckResult(
|
||||
CheckStatus.WARN,
|
||||
"mobile-first",
|
||||
"missing " + ", ".join(missing),
|
||||
)
|
||||
return CheckResult(CheckStatus.OK, "mobile-first", "PWA + mobile Playwright + a11y detected")
|
||||
|
||||
|
||||
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
|
||||
"""Type-specific extra checks beyond the expected dirs list."""
|
||||
results: list[CheckResult] = []
|
||||
|
|
@ -688,6 +777,9 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che
|
|||
frontend_stack = _check_frontend_stack(ctx)
|
||||
if frontend_stack is not None:
|
||||
results.append(frontend_stack)
|
||||
mobile = _check_mobile_first(ctx)
|
||||
if mobile is not None:
|
||||
results.append(mobile)
|
||||
if ptype == ProjectType.CLI:
|
||||
results.append(_check_cli_package(ctx))
|
||||
flat = _check_flat_layout(ptype, ctx)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ STACK_REQUIRED: dict[str, list[str]] = {
|
|||
"tailwind",
|
||||
"shadcn",
|
||||
"typescript",
|
||||
"mobile-first",
|
||||
],
|
||||
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
||||
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
||||
|
|
@ -78,3 +79,18 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
|
|||
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
||||
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
|
||||
}
|
||||
|
||||
# Fullstack mobile-first markers (project-status _check_mobile_first, issue #278).
|
||||
# Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on the
|
||||
# Tailwind/shadcn/TS trio. Keys:
|
||||
# fullstack_files — paths (relative to repo root) that must exist
|
||||
# for PWA + mobile Playwright + a11y to be present.
|
||||
# fullstack_app_html_markers — substrings that must appear in app.html <head>.
|
||||
MOBILE_FIRST_MARKERS: dict[str, list[str]] = {
|
||||
"fullstack_files": [
|
||||
"frontend/static/manifest.webmanifest",
|
||||
"frontend/tests/e2e/mobile.spec.ts",
|
||||
"frontend/tests/e2e/accessibility.spec.ts",
|
||||
],
|
||||
"fullstack_app_html_markers": ["viewport", "manifest"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ from __future__ import annotations
|
|||
|
||||
import functools
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
|
@ -111,8 +114,47 @@ class PhaseResult:
|
|||
detail: str
|
||||
|
||||
|
||||
def _forgejo_get(path: str) -> tuple[int, str, str]:
|
||||
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
|
||||
base = os.environ.get("FORGEJO_URL")
|
||||
token = os.environ.get("FORGEJO_TOKEN")
|
||||
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
|
||||
f"{base}/api/v1{path}",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
|
||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
||||
except OSError as e:
|
||||
return 0, "", str(e)
|
||||
|
||||
|
||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command, return (returncode, stdout, stderr)."""
|
||||
"""Run a command, return (returncode, stdout, stderr).
|
||||
|
||||
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh issue view`` is routed
|
||||
to the Forgejo REST API instead of spawning ``gh``. GitHub users (no
|
||||
``FORGEJO_URL``) see byte-identical behaviour — the gh/git subprocess path
|
||||
is untouched.
|
||||
"""
|
||||
if args and args[0] == "gh" and os.environ.get("FORGEJO_URL"):
|
||||
if not os.environ.get("FORGEJO_TOKEN"):
|
||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
||||
repo_idx = args.index("--repo") if "--repo" in args else -1
|
||||
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
||||
if (
|
||||
args[1:3] == ["issue", "view"]
|
||||
and repo
|
||||
and len(args) > 2
|
||||
and args[2].lstrip("-").isdigit()
|
||||
):
|
||||
sc, out_text, err = _forgejo_get(f"/repos/{repo}/issues/{args[2]}")
|
||||
if sc == 200:
|
||||
return 0, out_text, ""
|
||||
return 1, "", err or f"issue HTTP {sc}"
|
||||
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ REVIEW → MERGE).
|
|||
- service-слой пропущен (routes → db/models без services/)
|
||||
- файлы длиннее 200-300 строк (декомпозиция)
|
||||
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
|
||||
- mobile-first missing (fullstack): нет PWA manifest, нет Playwright mobile spec, нет axe a11y spec, нет viewport meta — `STACK_REQUIRED["fullstack"]` требует "mobile-first", но качественно проверь что mobile-first реален, а не просто слово в stack.md
|
||||
|
||||
Для каждой находки верни:
|
||||
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ src/<package>/
|
|||
|
||||
### Fullstack (кратко)
|
||||
|
||||
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот.
|
||||
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот. + mobile-first (PWA + Playwright mobile + axe a11y) — silent enforcement через `STACK_REQUIRED["fullstack"]`.
|
||||
|
||||
### CLI (кратко)
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ truth для порядка и действий — `spec-status` tool. На в
|
|||
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
|
||||
|
||||
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
|
||||
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
||||
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||
- **cli**: Typer (default) / click / argparse, hatchling build
|
||||
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||
|
|
@ -88,7 +88,7 @@ backend:
|
|||
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
||||
|
||||
fullstack:
|
||||
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default) / [2] add later
|
||||
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default, mobile-first: PWA + axe + Playwright mobile — silent) / [2] add later
|
||||
- DB: (same as backend)
|
||||
- Auth: (same as backend)
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ Spec complete. Issues: #N1, #N2, ...
|
|||
Default stack для типа (хардкод, добавить всегда):
|
||||
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
|
||||
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
|
||||
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
||||
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||
- cli: Typer (default) / click / argparse, hatchling build
|
||||
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||
|
|
|
|||
|
|
@ -46,9 +46,21 @@ jobs:
|
|||
- run: npm run lint
|
||||
- run: npm test
|
||||
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: frontend } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: npm install
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run test:e2e
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-lint, backend-typecheck, backend-test, frontend-test]
|
||||
needs: [backend-lint, backend-typecheck, backend-test, frontend-test, frontend-e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "All checks passed"
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
"knip": "^5.30.0",
|
||||
"svelte": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
|||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['iPhone SE'], isMobile: true, hasTouch: true } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run preview',
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<rect width="192" height="192" rx="32" fill="#0f172a" />
|
||||
<text x="96" y="128" font-family="system-ui, -apple-system, sans-serif" font-size="112" font-weight="700" fill="#f8fafc" text-anchor="middle">{{ cookiecutter.project_name | first | upper }}</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "{{ cookiecutter.project_name }}",
|
||||
"short_name": "{{ cookiecutter.project_name }}",
|
||||
"description": "{{ cookiecutter.description }}",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
test('dashboard has no a11y violations', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const results = await new AxeBuilder({ page })
|
||||
.disableRules(['color-contrast'])
|
||||
.analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('sidebar collapses on mobile viewport', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('[data-sidebar="sidebar"]')).not.toBeVisible();
|
||||
await expect(page.locator('[data-sidebar="trigger"]')).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { spawnSync } from "child_process"
|
||||
|
||||
type GhResult = { status: number | null; stdout: string; stderr: string }
|
||||
|
||||
/**
|
||||
* Build the `--repo <owner/repo>` argv fragment for `gh`.
|
||||
*
|
||||
|
|
@ -14,18 +16,167 @@ export function parseRepo(repo?: string): string[] {
|
|||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `owner/repo` for the Forgejo API path.
|
||||
*
|
||||
* `repo` is the explicit owner/name (the same string gh would receive via
|
||||
* `--repo`). When `repo` is omitted, infer it from the `origin` git remote
|
||||
* of the worktree at `opts.cwd` — this mirrors how `gh` auto-detects the
|
||||
* repo from cwd in GitHub mode. Returns `owner/name` or null if the remote
|
||||
* can't be parsed (caller surfaces an error).
|
||||
*/
|
||||
function resolveForgejoRepo(repo: string | undefined, opts?: { cwd?: string }): string | null {
|
||||
if (repo) return repo
|
||||
const cwd = opts?.cwd
|
||||
if (!cwd) return null
|
||||
const r = spawnSync("git", ["-C", cwd, "config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf-8",
|
||||
})
|
||||
if (r.status !== 0) return null
|
||||
const url = r.stdout.trim()
|
||||
const m = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||
return m ? `${m[1]}/${m[2]}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgejo REST API helper. Returns a spawnSync-shaped result so the caller's
|
||||
* `formatResult` / status-check code works unchanged. `okStatus` is the HTTP
|
||||
* status treated as success (200 for GET/POST-create, 204 for merge). Non-2xx
|
||||
* is reported as a non-zero `status` with the response body in `stderr`.
|
||||
*/
|
||||
async function callForgejo(
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
opts: { okStatus?: number; cwd?: string },
|
||||
): Promise<GhResult> {
|
||||
const base = process.env.FORGEJO_URL
|
||||
const token = process.env.FORGEJO_TOKEN
|
||||
const okStatus = opts.okStatus ?? 200
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
}
|
||||
if (body !== undefined) init.body = JSON.stringify(body)
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1${path}`, init)
|
||||
const text = await res.text()
|
||||
if (res.status === okStatus || (okStatus === 200 && res.status >= 200 && res.status < 300)) {
|
||||
return { status: 0, stdout: text, stderr: "" }
|
||||
}
|
||||
return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} → HTTP ${res.status}: ${text}` }
|
||||
} catch (e) {
|
||||
return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} failed: ${String(e)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `gh` with the given subcommand args, optionally targeting an explicit
|
||||
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
|
||||
* (callers pass `context.worktree`). Returns the raw spawnSync result so the
|
||||
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
|
||||
* `formatResult` for the standard error string.
|
||||
*
|
||||
* Dispatch (ADR-forgejo): when `FORGEJO_URL` is set, route to the Forgejo REST
|
||||
* API via `callForgejo` (translating the gh argv to the equivalent API call)
|
||||
* instead of spawning `gh`. GitHub users (no `FORGEJO_URL`) see byte-identical
|
||||
* behaviour — the `gh` path is untouched. The Forgejo result is shaped like a
|
||||
* spawnSync result (`{status, stdout, stderr}`) so callers don't branch.
|
||||
*/
|
||||
export function runGh(args: string[], repo?: string, opts?: { cwd?: string }) {
|
||||
export async function runGh(args: string[], repo?: string, opts?: { cwd?: string }): Promise<GhResult> {
|
||||
if (process.env.FORGEJO_URL) {
|
||||
return callForgejoGh(args, repo, opts)
|
||||
}
|
||||
const fullArgs = [...parseRepo(repo), ...args]
|
||||
return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd })
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the supported gh argv shapes to Forgejo API calls. Only the
|
||||
* commands used by the 4 GitHub tools are dispatched (pr merge, pr create,
|
||||
* issue create, pr comment); any other argv falls back to a non-zero
|
||||
* "unsupported in Forgejo mode" error so the dispatch is explicit.
|
||||
*/
|
||||
async function callForgejoGh(
|
||||
args: string[],
|
||||
repo: string | undefined,
|
||||
opts?: { cwd?: string },
|
||||
): Promise<GhResult> {
|
||||
const full = repo ?? resolveForgejoRepo(repo, opts)
|
||||
if (!full) {
|
||||
return {
|
||||
status: 1,
|
||||
stdout: "",
|
||||
stderr: "Forgejo mode requires owner/repo — none provided and origin remote not parseable",
|
||||
}
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "merge") {
|
||||
const n = args[2]
|
||||
return callForgejo(
|
||||
"POST",
|
||||
`/repos/${full}/pulls/${n}/merge`,
|
||||
{ Do: "squash", delete_branch_after_merge: true },
|
||||
{ okStatus: 200, cwd: opts?.cwd },
|
||||
)
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "create") {
|
||||
const titleIdx = args.indexOf("--title")
|
||||
const bodyIdx = args.indexOf("--body")
|
||||
const title = titleIdx >= 0 ? args[titleIdx + 1] : ""
|
||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
||||
const headIdx = args.indexOf("--head")
|
||||
const baseIdx = args.indexOf("--base")
|
||||
const head = headIdx >= 0 ? args[headIdx + 1] : undefined
|
||||
const base = baseIdx >= 0 ? args[baseIdx + 1] : undefined
|
||||
const r = await callForgejo(
|
||||
"POST",
|
||||
`/repos/${full}/pulls`,
|
||||
{ title, body, ...(head ? { head } : {}), ...(base ? { base } : {}) },
|
||||
{ cwd: opts?.cwd },
|
||||
)
|
||||
if (r.status !== 0) return r
|
||||
const pr = JSON.parse(r.stdout)
|
||||
return { status: 0, stdout: pr.html_url + "\n", stderr: "" }
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "comment") {
|
||||
const n = args[2]
|
||||
const bodyIdx = args.indexOf("--body")
|
||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
||||
return callForgejo(
|
||||
"POST",
|
||||
`/repos/${full}/issues/${n}/comments`,
|
||||
{ body },
|
||||
{ okStatus: 201, cwd: opts?.cwd },
|
||||
)
|
||||
}
|
||||
if (args[0] === "issue" && args[1] === "create") {
|
||||
const titleIdx = args.indexOf("--title")
|
||||
const bodyIdx = args.indexOf("--body")
|
||||
const labelIdx = args.indexOf("--label")
|
||||
const title = titleIdx >= 0 ? args[titleIdx + 1] : ""
|
||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
||||
const labels = labelIdx >= 0 ? args[labelIdx + 1].split(",") : []
|
||||
const r = await callForgejo(
|
||||
"POST",
|
||||
`/repos/${full}/issues`,
|
||||
{ title, body, labels },
|
||||
{ okStatus: 201, cwd: opts?.cwd },
|
||||
)
|
||||
if (r.status !== 0) return r
|
||||
const issue = JSON.parse(r.stdout)
|
||||
return { status: 0, stdout: issue.html_url + "\n", stderr: "" }
|
||||
}
|
||||
return {
|
||||
status: 1,
|
||||
stdout: "",
|
||||
stderr: `gh argv ${JSON.stringify(args)} not supported in Forgejo mode`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard success/error formatter for GitHub tools.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default tool({
|
|||
ghArgs.push("--label", args.labels.join(","))
|
||||
}
|
||||
|
||||
const r = runGh(ghArgs, args.repo, { cwd: context.worktree })
|
||||
const r = await runGh(ghArgs, args.repo, { cwd: context.worktree })
|
||||
if (r.status !== 0) {
|
||||
return formatResult(r, "gh issue create")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default tool({
|
|||
body = body + "\n\nCloses #" + args.issue_number
|
||||
}
|
||||
|
||||
const r = runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
|
||||
const r = await runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
|
||||
if (r.status !== 0) {
|
||||
return formatResult(r, "gh pr create")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -296,6 +296,46 @@ export default tool({
|
|||
})
|
||||
|
||||
if (args.repo) {
|
||||
const content64 = Buffer.from(content, "utf-8").toString("base64")
|
||||
if (process.env.FORGEJO_URL) {
|
||||
const base = process.env.FORGEJO_URL
|
||||
const token = process.env.FORGEJO_TOKEN
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
}
|
||||
let sha: string | undefined
|
||||
try {
|
||||
const getRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, { headers })
|
||||
if (getRes.status === 200) {
|
||||
sha = (await getRes.json()).sha
|
||||
} else if (getRes.status !== 404) {
|
||||
const text = await getRes.text()
|
||||
return `⚠️ create-readme failed: Forgejo GET README.md → HTTP ${getRes.status}: ${text}`
|
||||
}
|
||||
} catch (e) {
|
||||
return `⚠️ create-readme failed: Forgejo GET failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
const putBody: Record<string, string> = {
|
||||
content: content64,
|
||||
message: "docs: update README",
|
||||
}
|
||||
if (sha) putBody.sha = sha
|
||||
try {
|
||||
const putRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(putBody),
|
||||
})
|
||||
if (!putRes.ok) {
|
||||
const text = await putRes.text()
|
||||
return `⚠️ create-readme failed: Forgejo PUT README.md → HTTP ${putRes.status}: ${text}`
|
||||
}
|
||||
} catch (e) {
|
||||
return `⚠️ create-readme failed: Forgejo PUT failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
return `README.md updated in ${args.repo} via Forgejo API`
|
||||
}
|
||||
const getRes = spawnSync(
|
||||
"gh",
|
||||
["api", `repos/${args.repo}/contents/README.md`],
|
||||
|
|
@ -309,7 +349,6 @@ export default tool({
|
|||
sha = undefined
|
||||
}
|
||||
}
|
||||
const content64 = Buffer.from(content, "utf-8").toString("base64")
|
||||
const putArgs = [
|
||||
"api",
|
||||
"-X",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default tool({
|
|||
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const r = runGh(
|
||||
const r = await runGh(
|
||||
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
|
||||
args.repo,
|
||||
{ cwd: context.worktree },
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default tool({
|
|||
},
|
||||
async execute(args, context) {
|
||||
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
|
||||
const r = runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
|
||||
const r = await runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
|
||||
if (r.status !== 0) {
|
||||
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
|||
|
||||
export default tool({
|
||||
description:
|
||||
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура, Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
|
||||
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура (incl. mobile-first PWA + Playwright mobile + axe a11y for fullstack), Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
|
||||
args: {
|
||||
check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"),
|
||||
fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"),
|
||||
|
|
|
|||
|
|
@ -108,7 +108,8 @@ function stripTs(src) {
|
|||
out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "")
|
||||
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
|
||||
// Strip `export ` keyword on top-level declarations (shared modules).
|
||||
out = out.replace(/^export\s+(function|const|let|var)\b/gm, "$1")
|
||||
// Supports `export async function` (async helpers added for Forgejo dispatch).
|
||||
out = out.replace(/^export\s+(async\s+)?(function|const|let|var)\b/gm, "$1$2")
|
||||
// Strip type annotations on function params + return type:
|
||||
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
|
||||
// Handles single-line function signatures (used by _shared.ts). Object
|
||||
|
|
@ -116,7 +117,7 @@ function stripTs(src) {
|
|||
// Return type may itself be an object literal type (e.g. create-readme.ts
|
||||
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
|
||||
// so match greedily from `):` up to the final ` {` that opens the body.
|
||||
out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
|
||||
out = out.replace(/^(\s*(?:async\s+)?function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
|
||||
const cleaned = params
|
||||
.split(",")
|
||||
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
|
||||
|
|
|
|||
|
|
@ -258,6 +258,11 @@ def test_fullstack_structure(render):
|
|||
assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
|
||||
assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
|
||||
assert (render / "frontend/tests/e2e/app.spec.ts").exists()
|
||||
# issue #278: mobile-first — PWA manifest + SVG icon + mobile + a11y specs
|
||||
assert (render / "frontend/static/manifest.webmanifest").exists()
|
||||
assert (render / "frontend/static/icon.svg").exists()
|
||||
assert (render / "frontend/tests/e2e/mobile.spec.ts").exists()
|
||||
assert (render / "frontend/tests/e2e/accessibility.spec.ts").exists()
|
||||
# removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js
|
||||
# root CI runs both
|
||||
assert (render / ".github/workflows/ci.yml").exists()
|
||||
|
|
@ -397,6 +402,30 @@ def test_fullstack_uses_typescript(render):
|
|||
assert "tw-animate" not in deps or "tw-animate-css" in deps
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
|
||||
def test_fullstack_ci_has_frontend_e2e_job(render):
|
||||
"""Root ci.yml has a ``frontend-e2e`` job that installs deps without a
|
||||
lockfile (``npm install``, NOT ``npm ci``), wires Playwright, and runs
|
||||
in the ``frontend/`` working directory.
|
||||
|
||||
Regression guard for PR#281 review critical #1: cookiecutter templates
|
||||
ship no ``package-lock.json``, so ``npm ci`` fails in fresh projects.
|
||||
"""
|
||||
ci = (render / ".github/workflows/ci.yml").read_text()
|
||||
assert "frontend-e2e" in ci, "ci.yml must define a frontend-e2e job"
|
||||
assert "npm install" in ci, "ci.yml must use npm install (no lockfile in template)"
|
||||
assert "npm ci" not in ci, (
|
||||
"ci.yml must NOT use npm ci (cookiecutter template has no package-lock.json)"
|
||||
)
|
||||
assert "playwright install --with-deps" in ci, (
|
||||
"ci.yml must install Playwright browsers with --with-deps"
|
||||
)
|
||||
assert "test:e2e" in ci, "ci.yml must run the e2e suite (npm run test:e2e)"
|
||||
assert "working-directory: frontend" in ci, (
|
||||
"ci.yml must run frontend steps in the frontend/ working directory"
|
||||
)
|
||||
|
||||
|
||||
# ── pyproject.toml completeness ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,10 +42,11 @@ def _gh_available() -> bool:
|
|||
["gh", "auth", "status"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except FileNotFoundError:
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,28 @@ def test_frontend_stack_markers_keys():
|
|||
]
|
||||
|
||||
|
||||
def test_mobile_first_markers_keys():
|
||||
"""MOBILE_FIRST_MARKERS (issue #278) has the expected keys.
|
||||
|
||||
Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on
|
||||
the Tailwind/shadcn/TS trio while mobile-first (PWA + mobile Playwright +
|
||||
a11y) is a separate dict.
|
||||
"""
|
||||
assert set(pc.MOBILE_FIRST_MARKERS.keys()) == {
|
||||
"fullstack_files",
|
||||
"fullstack_app_html_markers",
|
||||
}
|
||||
assert pc.MOBILE_FIRST_MARKERS["fullstack_files"] == [
|
||||
"frontend/static/manifest.webmanifest",
|
||||
"frontend/tests/e2e/mobile.spec.ts",
|
||||
"frontend/tests/e2e/accessibility.spec.ts",
|
||||
]
|
||||
assert pc.MOBILE_FIRST_MARKERS["fullstack_app_html_markers"] == [
|
||||
"viewport",
|
||||
"manifest",
|
||||
]
|
||||
|
||||
|
||||
# ── re-export sanity (backward compat) ─────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -241,12 +241,28 @@ def _make_fullstack_repo(tmp_path: Path) -> None:
|
|||
# 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``.
|
||||
# Issue #278: mobile-first markers (manifest, mobile.spec.ts, a11y.spec.ts,
|
||||
# app.html viewport+manifest, @axe-core/playwright) are also present so the
|
||||
# default fixture mirrors a freshly generated fullstack cookiecutter repo.
|
||||
(tmp_path / "frontend" / "package.json").write_text(
|
||||
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
|
||||
'"bits-ui": "^1.0.0"}}\n'
|
||||
'"bits-ui": "^1.0.0"}, "devDependencies": {"@axe-core/playwright": "^4.10.0"}}\n'
|
||||
)
|
||||
(tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
|
||||
(tmp_path / "frontend" / "components.json").write_text("{}\n")
|
||||
(tmp_path / "frontend" / "static").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "frontend" / "static" / "manifest.webmanifest").write_text("{}\n")
|
||||
(tmp_path / "frontend" / "static" / "icon.svg").write_text("<svg></svg>\n")
|
||||
(tmp_path / "frontend" / "src").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "frontend" / "src" / "app.html").write_text(
|
||||
"<!doctype html><html><head>"
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />'
|
||||
'<link rel="manifest" href="/manifest.webmanifest" />'
|
||||
"</head><body></body></html>\n"
|
||||
)
|
||||
(tmp_path / "frontend" / "tests" / "e2e").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "frontend" / "tests" / "e2e" / "mobile.spec.ts").write_text("// mobile\n")
|
||||
(tmp_path / "frontend" / "tests" / "e2e" / "accessibility.spec.ts").write_text("// a11y\n")
|
||||
|
||||
|
||||
# ── parse_remote_url ─────────────────────────────────────────────────────────
|
||||
|
|
@ -2243,6 +2259,45 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
|
|||
assert "tsconfig.json" in frontend[0].detail
|
||||
|
||||
|
||||
# ── issue #278: mobile-first guarantee (fullstack only) ─────────────────────
|
||||
|
||||
|
||||
def test_fullstack_mobile_first_ok(tmp_path, ctx):
|
||||
"""All mobile-first markers present (manifest, mobile.spec.ts, a11y.spec.ts,
|
||||
app.html viewport+manifest, @axe-core/playwright) → OK."""
|
||||
_make_fullstack_repo(tmp_path)
|
||||
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||
mobile = [c for c in group.checks if c.name == "mobile-first"]
|
||||
assert mobile, f"expected 'mobile-first' check, got: {group.checks}"
|
||||
assert mobile[0].status == ps.CheckStatus.OK, (
|
||||
f"expected OK, got {mobile[0].status}: {mobile[0].detail}"
|
||||
)
|
||||
|
||||
|
||||
def test_fullstack_mobile_first_warns_missing_manifest(tmp_path, ctx):
|
||||
"""Missing ``frontend/static/manifest.webmanifest`` → WARN (non-blocking)."""
|
||||
_make_fullstack_repo(tmp_path)
|
||||
(tmp_path / "frontend" / "static" / "manifest.webmanifest").unlink()
|
||||
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||
mobile = [c for c in group.checks if c.name == "mobile-first"]
|
||||
assert mobile and mobile[0].status == ps.CheckStatus.WARN, (
|
||||
f"expected WARN for missing manifest, got: {mobile}"
|
||||
)
|
||||
assert "manifest.webmanifest" in mobile[0].detail
|
||||
|
||||
|
||||
def test_fullstack_mobile_first_skips_backend(tmp_path, ctx):
|
||||
"""Backend project type → ``_check_mobile_first`` not invoked (skip).
|
||||
|
||||
Mobile-first is a FULLSTACK-only guarantee; the structure group for
|
||||
backend must not contain a "mobile-first" check.
|
||||
"""
|
||||
_make_backend_repo(tmp_path)
|
||||
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
|
||||
mobile = [c for c in group.checks if c.name == "mobile-first"]
|
||||
assert not mobile, f"backend must skip mobile-first, got: {mobile}"
|
||||
|
||||
|
||||
# ── issue #274: db/models auto-detect from deps ──────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,26 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
|
||||
def _gh_available() -> bool:
|
||||
"""True if `gh auth status` succeeds (local dev machine, not CI runner)."""
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
["gh", "auth", "status"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
_GH_OK = _gh_available()
|
||||
_SKIP_REASON = "gh CLI not authenticated — skip real project-status.py call"
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
||||
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts"
|
||||
|
|
@ -150,6 +170,7 @@ def test_execute_uses_cwd_from_context():
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _GH_OK, reason=_SKIP_REASON)
|
||||
def test_execute_real_project_status():
|
||||
"""Integration: execute() returns the real project-status.py output (non-blocking)."""
|
||||
if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists():
|
||||
|
|
|
|||
|
|
@ -43,10 +43,11 @@ def _gh_available() -> bool:
|
|||
["gh", "auth", "status"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except FileNotFoundError:
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue