Compare commits

...
Sign in to create a new pull request.

10 commits
pr-280 ... main

Author SHA1 Message Date
opencode-agent
a08385c9dd ci: use preinstalled tools from custom runner image (drop setup-uv/python/node actions)
All checks were successful
CI / bootstrap (push) Successful in 22s
CI / complexity (push) Successful in 41s
CI / lint (push) Successful in 41s
CI / typecheck (push) Successful in 41s
CI / test (3.13) (push) Successful in 1m43s
2026-08-06 16:30:34 +00:00
opencode-agent
7e4dfcc38b ci: reduce python matrix to 3.13 and enable uv/npm caches
Some checks failed
CI / bootstrap (push) Successful in 22s
CI / typecheck (push) Failing after 1m49s
CI / complexity (push) Successful in 2m10s
CI / lint (push) Successful in 2m11s
CI / test (3.13) (push) Successful in 3m41s
2026-08-06 16:02:15 +00:00
opencode-agent
d16c2434a3 docs(env-example): add Forgejo API env vars
Some checks failed
CI / bootstrap (push) Successful in 17s
CI / lint (push) Failing after 14s
CI / typecheck (push) Successful in 40s
CI / complexity (push) Successful in 49s
CI / test (3.14) (push) Successful in 2m24s
CI / test (3.13) (push) Successful in 2m42s
CI / test (3.12) (push) Successful in 2m56s
2026-08-06 15:51:23 +00:00
opencode-agent
ab0c3dd68d test(ci): add timeout to _gh_available to prevent hang on Forgejo runner
Some checks failed
CI / bootstrap (push) Successful in 5s
CI / typecheck (push) Failing after 14s
CI / complexity (push) Successful in 43s
CI / test (3.12) (push) Successful in 2m30s
CI / test (3.13) (push) Successful in 2m32s
CI / test (3.14) (push) Successful in 2m26s
CI / lint (push) Successful in 29s
2026-08-06 15:48:02 +00:00
opencode-agent
9dabc57d9a feat(oracles): add Forgejo backend to status scripts
Some checks failed
CI / bootstrap (push) Successful in 9s
CI / lint (push) Successful in 21s
CI / typecheck (push) Successful in 25s
CI / complexity (push) Successful in 30s
CI / test (3.14) (push) Successful in 1m47s
CI / test (3.13) (push) Successful in 1m48s
CI / test (3.12) (push) Failing after 13m11s
2026-08-06 15:25:42 +00:00
opencode-agent
0fbf1d97bc feat(create-readme): support Forgejo contents API
All checks were successful
CI / bootstrap (push) Successful in 11s
CI / lint (push) Successful in 26s
CI / typecheck (push) Successful in 33s
CI / test (3.12) (push) Successful in 1m39s
CI / test (3.13) (push) Successful in 2m21s
CI / test (3.14) (push) Successful in 1m26s
CI / complexity (push) Successful in 20s
2026-08-06 15:11:23 +00:00
opencode-agent
93e7843438 feat(tools): add Forgejo backend with gh CLI fallback
All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 17s
CI / typecheck (push) Successful in 25s
CI / test (3.12) (push) Successful in 2m48s
CI / test (3.13) (push) Successful in 1m41s
CI / test (3.14) (push) Successful in 1m34s
CI / complexity (push) Successful in 21s
2026-08-06 15:02:20 +00:00
opencode-agent
77143ac6da test(project-status): skip exec_real when gh CLI unavailable
All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 20s
CI / typecheck (push) Successful in 28s
CI / test (3.12) (push) Successful in 1m41s
CI / test (3.13) (push) Successful in 1m35s
CI / test (3.14) (push) Successful in 1m28s
CI / complexity (push) Successful in 29s
2026-08-06 14:45:18 +00:00
Sergey
9459e47567
feat(spec): mobile-first silent enforcement in STACK_REQUIRED + CI e2e (#281)
Some checks failed
CI / bootstrap (push) Successful in 52s
CI / lint (push) Successful in 2m2s
CI / typecheck (push) Successful in 27s
CI / test (3.12) (push) Failing after 2m53s
CI / test (3.13) (push) Failing after 2m0s
CI / test (3.14) (push) Failing after 1m48s
CI / complexity (push) Successful in 23s
* feat(spec): mobile-first silent enforcement in STACK_REQUIRED + Template C

* feat(ci): frontend-e2e job with Playwright in fullstack cookiecutter

* docs(skills): mention mobile-first in audit, code-standards, project-status tool

* fix(ci): use npm install instead of npm ci + add e2e regression test

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 07:05:14 +03:00
Sergey
6bb115e706
feat(fullstack): mobile-first guarantee — cookiecutter + project-status (#280)
* feat(scripts): add MOBILE_FIRST_MARKERS + _check_mobile_first check

* feat(templates): fullstack mobile-first PWA manifest + mobile Playwright + axe

* test(scripts): cover mobile-first markers, check, cookiecutter structure

* fix(ci): reformat test_project_status.py string literals

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 06:46:20 +03:00
31 changed files with 683 additions and 46 deletions

View file

@ -18,6 +18,10 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
# GitHub # GitHub
GITHUB_TOKEN=your-github-token-here 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 MCP
CONTEXT7_API_KEY=your-context7-api-key-here CONTEXT7_API_KEY=your-context7-api-key-here

View file

@ -35,10 +35,6 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run ruff check src/ tests/ .opencode/scripts/ - run: uv run ruff check src/ tests/ .opencode/scripts/
- run: uv run ruff format --check src/ tests/ .opencode/scripts/ - run: uv run ruff format --check src/ tests/ .opencode/scripts/
@ -50,10 +46,6 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run mypy src/ - run: uv run mypy src/
@ -65,23 +57,15 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python: ["3.12", "3.13", "3.14"] python: ["3.13"]
steps: steps:
- uses: actions/checkout@v4 - 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 - run: npm ci
working-directory: .opencode working-directory: .opencode
- run: npm ci - run: npm ci
working-directory: .opencode/draw-image working-directory: .opencode/draw-image
- run: npm test - run: npm test
working-directory: .opencode/draw-image working-directory: .opencode/draw-image
- uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }} - run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run --python ${{ matrix.python }} pytest - run: uv run --python ${{ matrix.python }} pytest
@ -92,9 +76,5 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - 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 sync --extra dev
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/ - run: uv run xenon --max-absolute B --max-modules A --max-average A src/

View file

@ -28,6 +28,8 @@ import re
import subprocess import subprocess
import sys import sys
import time import time
import urllib.error
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@ -85,10 +87,135 @@ class PhaseResult:
detail: str 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]: def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr).""" """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 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) @dataclass(frozen=True)

View file

@ -37,10 +37,13 @@ import argparse
import ast import ast
import importlib.util import importlib.util
import json import json
import os
import re import re
import subprocess import subprocess
import sys import sys
import tomllib import tomllib
import urllib.error
import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@ -187,8 +190,50 @@ class RepoCtx:
# ── helpers ────────────────────────────────────────────────────────────────── # ── 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]: 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) result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr 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") 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]: 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] = []
@ -688,6 +777,9 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che
frontend_stack = _check_frontend_stack(ctx) frontend_stack = _check_frontend_stack(ctx)
if frontend_stack is not None: if frontend_stack is not None:
results.append(frontend_stack) results.append(frontend_stack)
mobile = _check_mobile_first(ctx)
if mobile is not None:
results.append(mobile)
if ptype == ProjectType.CLI: 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)

View file

@ -51,6 +51,7 @@ STACK_REQUIRED: dict[str, list[str]] = {
"tailwind", "tailwind",
"shadcn", "shadcn",
"typescript", "typescript",
"mobile-first",
], ],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"], "mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"], "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_package_deps": ["tailwindcss", "bits-ui"],
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"], "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"],
}

View file

@ -38,9 +38,12 @@ from __future__ import annotations
import functools import functools
import importlib.util import importlib.util
import os
import re import re
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@ -111,8 +114,47 @@ class PhaseResult:
detail: str 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]: 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) result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr

View file

@ -96,6 +96,7 @@ REVIEW → MERGE).
- service-слой пропущен (routes → db/models без services/) - service-слой пропущен (routes → db/models без services/)
- файлы длиннее 200-300 строк (декомпозиция) - файлы длиннее 200-300 строк (декомпозиция)
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление) - 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"} {category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}

View file

@ -104,7 +104,7 @@ src/<package>/
### Fullstack (кратко) ### 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 (кратко) ### CLI (кратко)

View file

@ -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. Общий для всех типов: 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`) - **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 - **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build - **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.) - **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 - Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack: 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) - DB: (same as backend)
- Auth: (same as backend) - Auth: (same as backend)
@ -236,7 +236,7 @@ Spec complete. Issues: #N1, #N2, ...
Default stack для типа (хардкод, добавить всегда): Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI - Общий: 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`) - 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 - mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build - cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.) - bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)

View file

@ -46,9 +46,21 @@ jobs:
- run: npm run lint - run: npm run lint
- run: npm test - 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: build:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: echo "All checks passed" - run: echo "All checks passed"

View file

@ -30,6 +30,7 @@
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@axe-core/playwright": "^4.10.0",
"@biomejs/biome": "^1.9.0", "@biomejs/biome": "^1.9.0",
"knip": "^5.30.0", "knip": "^5.30.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",

View file

@ -12,6 +12,7 @@ export default defineConfig({
}, },
projects: [ projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['iPhone SE'], isMobile: true, hasTouch: true } },
], ],
webServer: { webServer: {
command: 'npm run preview', command: 'npm run preview',

View file

@ -2,8 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

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

View file

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

View file

@ -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([]);
});

View file

@ -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();
});

View file

@ -1,5 +1,7 @@
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
type GhResult = { status: number | null; stdout: string; stderr: string }
/** /**
* Build the `--repo <owner/repo>` argv fragment for `gh`. * Build the `--repo <owner/repo>` argv fragment for `gh`.
* *
@ -14,18 +16,167 @@ export function parseRepo(repo?: string): string[] {
return [] 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 * Run `gh` with the given subcommand args, optionally targeting an explicit
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd` * repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
* (callers pass `context.worktree`). Returns the raw spawnSync result so the * (callers pass `context.worktree`). Returns the raw spawnSync result so the
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to * caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
* `formatResult` for the standard error string. * `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] const fullArgs = [...parseRepo(repo), ...args]
return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd }) 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. * Standard success/error formatter for GitHub tools.
* *

View file

@ -67,7 +67,7 @@ export default tool({
ghArgs.push("--label", args.labels.join(",")) 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) { if (r.status !== 0) {
return formatResult(r, "gh issue create") return formatResult(r, "gh issue create")
} }

View file

@ -53,7 +53,7 @@ export default tool({
body = body + "\n\nCloses #" + args.issue_number 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) { if (r.status !== 0) {
return formatResult(r, "gh pr create") return formatResult(r, "gh pr create")
} }

View file

@ -296,6 +296,46 @@ export default tool({
}) })
if (args.repo) { 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( const getRes = spawnSync(
"gh", "gh",
["api", `repos/${args.repo}/contents/README.md`], ["api", `repos/${args.repo}/contents/README.md`],
@ -309,7 +349,6 @@ export default tool({
sha = undefined sha = undefined
} }
} }
const content64 = Buffer.from(content, "utf-8").toString("base64")
const putArgs = [ const putArgs = [
"api", "api",
"-X", "-X",

View file

@ -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."), repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
}, },
async execute(args, context) { async execute(args, context) {
const r = runGh( const r = await runGh(
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"], ["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
args.repo, args.repo,
{ cwd: context.worktree }, { cwd: context.worktree },

View file

@ -14,7 +14,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}` 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) { if (r.status !== 0) {
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}` return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
} }

View file

@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
export default tool({ export default tool({
description: 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: { args: {
check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"), 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)"), fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"),

View file

@ -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*$/gms, "")
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "") out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
// Strip `export ` keyword on top-level declarations (shared modules). // 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: // Strip type annotations on function params + return type:
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {` // `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
// Handles single-line function signatures (used by _shared.ts). Object // 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 // Return type may itself be an object literal type (e.g. create-readme.ts
// `validateReadme(content: string): { ok: boolean; issues: string[] }`), // `validateReadme(content: string): { ok: boolean; issues: string[] }`),
// so match greedily from `):` up to the final ` {` that opens the body. // 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 const cleaned = params
.split(",") .split(",")
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim()) .map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())

View file

@ -258,6 +258,11 @@ def test_fullstack_structure(render):
assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists() assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists() assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
assert (render / "frontend/tests/e2e/app.spec.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 # removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js
# root CI runs both # root CI runs both
assert (render / ".github/workflows/ci.yml").exists() 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 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 ────────────────────────────────────────────── # ── pyproject.toml completeness ──────────────────────────────────────────────

View file

@ -42,10 +42,11 @@ def _gh_available() -> bool:
["gh", "auth", "status"], ["gh", "auth", "status"],
capture_output=True, capture_output=True,
check=False, check=False,
timeout=10,
).returncode ).returncode
== 0 == 0
) )
except FileNotFoundError: except (FileNotFoundError, subprocess.TimeoutExpired):
return False return False

View file

@ -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) ───────────────────────────────────── # ── re-export sanity (backward compat) ─────────────────────────────────────

View file

@ -241,12 +241,28 @@ def _make_fullstack_repo(tmp_path: Path) -> None:
# Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS # Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
# (issue #266): package.json deps + components.json + tsconfig.json are the # (issue #266): package.json deps + components.json + tsconfig.json are the
# 4 frontend stack markers checked by ``_check_frontend_stack``. # 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( (tmp_path / "frontend" / "package.json").write_text(
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", ' '{"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" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
(tmp_path / "frontend" / "components.json").write_text("{}\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 ───────────────────────────────────────────────────────── # ── parse_remote_url ─────────────────────────────────────────────────────────
@ -2243,6 +2259,45 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
assert "tsconfig.json" in frontend[0].detail 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 ────────────────────────────── # ── issue #274: db/models auto-detect from deps ──────────────────────────────

View file

@ -30,6 +30,26 @@ from pathlib import Path
import pytest 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 REPO_ROOT = Path(__file__).resolve().parent.parent
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts" 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(): def test_execute_real_project_status():
"""Integration: execute() returns the real project-status.py output (non-blocking).""" """Integration: execute() returns the real project-status.py output (non-blocking)."""
if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists(): if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists():

View file

@ -43,10 +43,11 @@ def _gh_available() -> bool:
["gh", "auth", "status"], ["gh", "auth", "status"],
capture_output=True, capture_output=True,
check=False, check=False,
timeout=10,
).returncode ).returncode
== 0 == 0
) )
except FileNotFoundError: except (FileNotFoundError, subprocess.TimeoutExpired):
return False return False