diff --git a/.github/workflows/adr-check.yml b/.github/workflows/adr-check.yml deleted file mode 100644 index e8a114c..0000000 --- a/.github/workflows/adr-check.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: ADR References Check - -on: - pull_request: - paths: - - 'docs/**' - - '.opencode/**' - - '**/*.md' - - '.github/workflows/adr-check.yml' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - id: check - run: | - if [ -f .opencode/scripts/check-adr-refs.py ]; then - echo "has_script=true" >> $GITHUB_OUTPUT - else - echo "has_script=false" >> $GITHUB_OUTPUT - fi - - uses: actions/setup-python@v5 - if: steps.check.outputs.has_script == 'true' - with: - python-version: "3.12" - - run: python3 .opencode/scripts/check-adr-refs.py - if: steps.check.outputs.has_script == 'true' \ No newline at end of file diff --git a/.opencode/agents/docs-reviewer.md b/.opencode/agents/docs-reviewer.md deleted file mode 100644 index 22888c5..0000000 --- a/.opencode/agents/docs-reviewer.md +++ /dev/null @@ -1,256 +0,0 @@ ---- -description: Reviews and updates project map documentation before code review. Auto-commits updates to PR branch. Posts verdict via post-docs-review tool (deterministic heading for pipeline-status.py). -mode: subagent -temperature: 0.1 -steps: 150 -permission: - edit: allow - doom_loop: deny - bash: - "*": deny - "git fetch*": allow - "git diff*": allow - "git log*": allow - "git status*": allow - "git show*": allow - "git blame*": allow - "git remote -v*": allow - "git remote show*": allow - "git add docs/project-map*": allow - "git add docs/handoff*": allow - "git add docs/decisions*": allow - "git push*": allow - "rg *": allow - "find *": allow - "ls *": allow - "cat *": allow - "head*": allow - "tail*": allow - "wc*": allow - "diff*": allow - "repomix*": allow - "gh pr view*": allow - "gh pr diff*": allow - "gh pr checkout*": allow - "gh pr comment*": allow - "gh pr comment *": allow - "gh issue view*": allow - "gh issue list*": allow - "git rm docs/handoff*": allow - "git rm docs/decisions*": allow - "git rm -r docs/spec*": allow - "git rm --cached docs/handoff*": allow - "git rm --cached docs/decisions*": allow - "git mv docs/handoff*": allow - "git mv docs/decisions*": allow - "git checkout docs/handoff*": allow - "git checkout docs/decisions*": allow - "git -C * status*": allow - "git -C * diff*": allow - "git -C * log*": allow - "git -C * show*": allow - "git -C * branch*": allow - "git -C * fetch*": allow - "git -C * remote -v*": allow - "git -C * remote show*": allow - "git branch*": allow - "pwd": allow - "echo *": allow - "python3*": allow - "python3 *pipeline-status.py*": deny - "python3 .opencode/scripts/pipeline-status.py*": deny - "python3 */pipeline-status.py*": deny - "python *pipeline-status.py*": deny - "python */pipeline-status.py*": deny - "python3 *spec-status.py*": deny - "python3 .opencode/scripts/spec-status.py*": deny - "python3 */spec-status.py*": deny - "python *spec-status.py*": deny - "python */spec-status.py*": deny - "mkdir*": allow - "gh pr merge*": deny ---- - -You are a project map reviewer. Your job: analyze structural changes in a PR, update the project map documentation in `docs/project-map/`, and commit updates to the PR branch. - -## Setup - -1. Run `gh pr view --json headRefName,title,body` to get branch name and PR context. -2. Run `gh pr checkout ` to switch to the PR branch. -3. Run `git fetch origin` to ensure you have the latest default branch. -4. Run `git diff origin/HEAD...HEAD --stat` to see what files changed. -5. Run `repomix --no-files --stdout` to get the current directory tree. -6. Check if `docs/project-map/` exists: - - Run `ls docs/project-map/ 2>/dev/null` - - If it doesn't exist, create the directory and an initial `README.md`. - -## Analysis - -1. Compare the `git diff --stat` output with the current `docs/project-map/` files. -2. Determine if structural changes occurred: - - New files or directories added - - Files or directories deleted - - Files or directories renamed - - New top-level modules -3. If NO structural changes (only content edits, bug fixes, refactoring within existing files) → skip project-map update, but STILL leave PR comment per "PR Comment (mandatory)" section below. -4. If structural changes occurred → proceed to update. - -## Handoff & ADR Validation - -After updating project map, validate handoff and ADR files: - -### Handoff (`docs/handoff/pr--.md`) -1. Check if file exists. If not → create it from PR diff. -2. Check all sections are present: Что сделано, Почему, Pending, Watch out. -3. Check content is meaningful (not empty placeholders). -4. If sections missing or empty → **fix them** based on PR diff and issue context. - -### ADR (`docs/decisions/-.md`) — mandatory per ADR-002 - -1. ADR is **mandatory** in every PR (per ADR-002). Never bypass. -2. If ADR file `docs/decisions/*-pr-<PR#>-*.md` does not exist → create it via `bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug>` (creates both handoff + ADR templates). -3. Check ADR sections: Статус, Контекст, Решение, Альтернативы. -4. If sections incomplete (empty placeholders like `<заполни>`) → **fix them** based on PR diff. -5. If PR has NO architectural decisions → fill all sections (Контекст/Решение/Альтернативы) with `—` (em-dash). This is valid per ADR-002. -6. NEVER bypass ADR creation. Pipeline-status.py will fail DOCS phase if ADR is missing. - -## Spec cleanup (post-merge, опционально) - -**GUARD: НЕ выполнять spec cleanup pre-merge — это post-merge операция.** -Spec cleanup выполняется ТОЛЬКО post-merge (в MEMORY фазе), НЕ pre-merge. -Если ты docs-reviewer (pre-merge), пропусти эту секцию — spec cleanup -выполняет memory-syncer post-merge. - -Если `docs/spec/roadmap.md` существует в репо (spec был запущен): -1. Извлеки все `#N` номера issues из `docs/spec/roadmap.md` (regex `#(\d+)`). -2. Для каждого `#N`: `gh issue view N --json state --jq .state`. -3. Если ВСЕ issues имеют `state=CLOSED`: - - `git rm -r docs/spec/` (удаляет всю директорию spec-документации). - - Коммит через `commit` tool: `commit({ message: "chore: remove completed spec" })`. - - PR comment: добавить секцию `## Spec Cleanup` в docs-review summary: "Spec removed: all N issues from roadmap.md are CLOSED". -4. Если хотя бы один issue OPEN → пропусти cleanup (spec ещё жив). PR comment: "Spec retained: M/N issues still OPEN". - -Проверка выполняется ПОСЛЕ валидации handoff/ADR и ДО `git add docs/project-map/ docs/handoff/ docs/decisions/`. - -Используется `git rm -r docs/spec/` (НЕ `rm -rf docs/spec/`) — `rm -rf` блокируется `check-permissions.py` (DANGEROUS_PATTERNS, scope=all). `git rm -r` семантически эквивалентен и соответствует существующим паттернам `git rm docs/handoff*` / `git rm docs/decisions*`. - -### Commit scope -When committing, stage docs first, then use the `commit` tool (raw `git commit` is globally denied — use the tool which bypasses via spawnSync): -```bash -git add docs/project-map/ docs/handoff/ docs/decisions/ -``` -``` -commit({ message: "docs: update project map + handoff + ADR" }) -``` -```bash -git push -``` - -## Update Rules - -### What to include in project map: -- Directory structure (tree of each module) -- Purpose of each module/directory -- Key files and their roles -- Dependencies between modules - -### What NOT to include: -- Implementation details -- API signatures -- Internal logic -- Line-by-line documentation - -### File structure: -- `docs/project-map/README.md` — index, overall project structure, module list -- `docs/project-map/<module>.md` — one file per top-level module/directory - -### MD file template: -```markdown ---- -module: <module-path> -purpose: <one-line description> -key_files: - - <path> — <role> - - <path> — <role> -dependencies: [<list of module dependencies>] -last_updated: <YYYY-MM-DD> ---- - -# <module name> - -## Structure -- `<file>` — <description> -- `<file>` — <description> - -## Patterns -- <pattern or convention used> -``` - -## Commit - -1. Stage only project map files: - ```bash - git add docs/project-map/ docs/handoff/ docs/decisions/ - ``` -2. Commit via `commit` tool (raw `git commit` is globally denied — the tool bypasses via spawnSync): - ``` - commit({ message: "docs(project-map): update after structural changes" }) - ``` -3. Push: - ```bash - git push - ``` - -## PR Comment (mandatory) - -After validation (regardless of whether structural changes occurred), **ALWAYS** leave a PR comment using the `post-docs-review` tool. The tool auto-generates the `## Docs Review Summary` heading and the `### Verdict: <verdict>` line — you only pass the body content (between heading and verdict). Do NOT manually format the heading or verdict. This is the deterministic marker that `check_docs` in pipeline-status.py uses to prove docs-reviewer ran. Without this comment, the pipeline is blocked at DOCS phase. - -Body format (without heading — tool adds `## Docs Review Summary` and `### Verdict: <verdict>`): -``` -- Project map: <updated|no structural changes|created> -- Handoff: <valid|fixed: ...|missing: ...> -- ADR: <valid|fixed: ...|missing: ...> - -## Spec Cleanup -- Spec: <removed: all N issues from roadmap.md are CLOSED|retained: M/N issues still OPEN|n/a: docs/spec/roadmap.md does not exist> -``` - -Call: -``` -post-docs-review({ pr_number: <PR_NUMBER>, verdict: "<APPROVE|FIXED|NO_CHANGES>", body: `<body text above>` }) -``` - -Verdict semantics: -- `APPROVE` — docs valid, no fixes required -- `FIXED` — docs-reviewer fixed something (project map, handoff, ADR, or spec cleanup performed) -- `NO_CHANGES` — no structural changes, handoff+ADR already valid, spec retained or absent (no commit, no edits) - -Rules: -1. Comment is left AFTER commit+push (if any) — so reviewer can see final state. -2. If no structural changes AND handoff+ADR valid → no commit, but comment IS still left with `Verdict: NO_CHANGES`. -3. The comment heading `## Docs Review Summary` is guaranteed by the `post-docs-review` tool — `check_docs` matches regex `Docs Review` (case-insensitive). -4. Never skip the comment, even on edge cases — use `Verdict: NO_CHANGES` instead of silence. - -## Tool failure handling - -If `post-docs-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️ post-docs-review failed for PR #N (exit 1): ...`): -- **СООБЩИ оркестратору о сбое tool и STOP.** Не продолжай молча, не пытайся fallback на raw `gh pr comment` через bash. -- Причина сбоя обычно: gh не аутентифицирован, PR не найден в текущем репо (cwd не git-репо или нет origin remote), или network error. -- Возвращай текст вида: `⚠️ post-docs-review tool failed: <сообщение от tool>. Pipeline заблокирован на DOCS phase — требуется вмешательство.` -- Любой дальнейший tool call после сбоя = protocol violation. - -## Rules - -1. ALWAYS checkout the PR branch first. -2. ONLY edit files in `docs/project-map/`, `docs/handoff/`, `docs/decisions/`. -3. ONLY `git add docs/project-map/ docs/handoff/ docs/decisions/` — never stage other files. -4. If no structural changes → do not commit, but STILL leave PR comment (see "PR Comment (mandatory)" section). -5. Keep map files concise — structure and purpose, not implementation. -6. Update `last_updated` field in frontmatter when modifying a file. -7. If `docs/project-map/` doesn't exist → create initial map with `README.md` and one file per top-level module. -8. Для debug-вывода используй `pwd`/`ls`/`cat`/`echo` (все в allow-list). -9. НЕ переключайся на master и НЕ делай `git pull` — работай только на PR branch (checkout уже сделан в Setup). - -## Bug Discovery - -If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...". diff --git a/.opencode/opencode.json b/.opencode/opencode.json index c3ad146..3b62b7d 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -287,8 +287,7 @@ "create_pr": true, "create_issue": true, "merge_pr": false, - "post_review": false, - "post_docs_review": false + "post_review": false } }, "reviewer": { @@ -297,18 +296,7 @@ "create_pr": false, "create_issue": true, "merge_pr": false, - "post_review": true, - "post_docs_review": false - } - }, - "docs-reviewer": { - "tools": { - "commit": true, - "create_pr": false, - "create_issue": true, - "merge_pr": false, - "post_review": false, - "post_docs_review": true + "post_review": true } }, "memory-syncer": { @@ -317,8 +305,7 @@ "create_pr": false, "create_issue": true, "merge_pr": false, - "post_review": false, - "post_docs_review": false + "post_review": false } } }, diff --git a/.opencode/scripts/check-adr-refs.py b/.opencode/scripts/check-adr-refs.py deleted file mode 100755 index 2b1b244..0000000 --- a/.opencode/scripts/check-adr-refs.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Check ADR references in markdown files for dangling pointers. - -Scans ``.md`` files in the repo for ``ADR-NNN`` references and verifies -that a corresponding ``docs/decisions/NNN-*.md`` file exists. Dangling -references (typos, forward-refs) fail CI — symmetric to -``check-permissions.py`` (ADR-006 pattern). - -Self-reference is excluded: if the current file's name starts with -``NNN-``, a reference to ``ADR-NNN`` inside it is OK (an ADR file may -mention its own number). - -What is NOT caught: wrong existing refs (``ADR-017`` exists but is -semantically wrong for a given PR) — that requires semantic analysis. -""" - -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent.parent -ADR_DIR = REPO_ROOT / "docs" / "decisions" -ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b") - -EXCLUDE_DIRS = {"node_modules", ".git", "app_data", ".opencode"} - - -def _is_excluded(path: Path) -> bool: - """True if ``path`` is inside an excluded directory (node_modules, .git, ...).""" - try: - rel = path.relative_to(REPO_ROOT) - except ValueError: - return True - parts = rel.parts - return any(excl in parts for excl in EXCLUDE_DIRS) - - -def find_md_files() -> list[Path]: - """Return all ``.md`` files under REPO_ROOT, excluding node_modules/.git/etc.""" - results: list[Path] = [] - for md_file in REPO_ROOT.rglob("*.md"): - if _is_excluded(md_file): - continue - results.append(md_file) - return sorted(results) - - -def find_adr_refs(md_file: Path) -> list[tuple[int, str]]: - """Return ``[(line_number, adr_number), ...]`` for every ``ADR-NNN`` in ``md_file``.""" - refs: list[tuple[int, str]] = [] - text = md_file.read_text(encoding="utf-8", errors="replace") - for line_no, line in enumerate(text.splitlines(), start=1): - for match in ADR_REF_RE.finditer(line): - refs.append((line_no, match.group(1))) - return refs - - -def is_self_reference(md_file: Path, adr_number: str) -> bool: - """True if ``md_file``'s name starts with ``NNN-`` where NNN == adr_number.""" - return md_file.name.startswith(f"{adr_number}-") - - -def adr_exists(adr_number: str) -> bool: - """True if ``docs/decisions/NNN-*.md`` exists for the given number.""" - if not ADR_DIR.exists(): - return False - return any(ADR_DIR.glob(f"{adr_number}-*.md")) - - -def main() -> None: - all_violations: list[str] = [] - for md_file in find_md_files(): - refs = find_adr_refs(md_file) - for ref_line, ref_number in refs: - if is_self_reference(md_file, ref_number): - continue - if not adr_exists(ref_number): - all_violations.append( - f" {md_file.relative_to(REPO_ROOT)}:{ref_line}: " - f"ADR-{ref_number} reference, but " - f"docs/decisions/{ref_number}-*.md does not exist" - ) - - if not all_violations: - print("OK: No dangling ADR references.") - sys.exit(0) - - print("FAIL: Dangling ADR references:\n") - for v in all_violations: - print(v) - print(f"\nTotal: {len(all_violations)} violation(s)") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.opencode/scripts/scaffold-handoff.sh b/.opencode/scripts/scaffold-handoff.sh deleted file mode 100755 index e2d6fe9..0000000 --- a/.opencode/scripts/scaffold-handoff.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Create handoff + ADR templates for a PR. -# Usage: bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug> -# Idempotent: does not overwrite existing files. -set -euo pipefail - -PR="${1:?Usage: scaffold-handoff.sh <PR#> <slug>}" -SLUG="${2:?Usage: scaffold-handoff.sh <PR#> <slug>}" - -REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "")" -if [ -z "$REPO_ROOT" ]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -fi - -HANDOFF_DIR="$REPO_ROOT/docs/handoff" -ADR_DIR="$REPO_ROOT/docs/decisions" -mkdir -p "$HANDOFF_DIR" "$ADR_DIR" - -HANDOFF="$HANDOFF_DIR/pr-${PR}-${SLUG}.md" -TODAY="$(date +%Y-%m-%d)" - -if [ -f "$HANDOFF" ]; then - echo "handoff already exists: $HANDOFF" -else - cat > "$HANDOFF" <<EOF ---- -pr: ${PR} -title: <заполни> ---- - -## Что сделано -<заполни> - -## Почему -<заполни> - -## Pending -<заполни, или «—»> - -## Watch out -<заполни, или «—»> -EOF - echo "created: $HANDOFF" -fi - -EXISTING_ADR="$(ls "$ADR_DIR"/*-pr-${PR}-${SLUG}.md 2>/dev/null | head -n1 || true)" -if [ -n "$EXISTING_ADR" ]; then - ADR="$EXISTING_ADR" - echo "ADR already exists: $ADR" -else - # Next ADR number = max(existing) + 1, NOT count + 1. - # count+1 collides when files are added out of order or in parallel - # (e.g. two PRs both computed wc -l = 72 → both wrote ADR-073, see issue #176). - # max+1 is race-resistant: each run reads the current highest number. - # Guard loop handles the residual race: if max+1 file already exists - # (created between the max-read and our write), increment to a free slot. - # 10# forces base-10 (leading zeros like 078 are invalid octal in bash). - # `|| true` neutralizes grep's exit 1 under set -euo pipefail when the - # directory is empty (no ADRs yet → max=0 → next=001). - MAX_N="$(ls "$ADR_DIR" 2>/dev/null | grep -E '^[0-9]{3}-' | cut -c1-3 | sort -n | tail -n1 || true)" - NEXT_N=$((10#${MAX_N:-0} + 1)) - while :; do - NN="$(printf "%03d" "$NEXT_N")" - ADR="$ADR_DIR/${NN}-pr-${PR}-${SLUG}.md" - if ! ls "$ADR_DIR"/${NN}-pr-*.md >/dev/null 2>&1; then - break - fi - NEXT_N=$((NEXT_N + 1)) - done - cat > "$ADR" <<EOF -# ADR-${NN}: <title> - -## Статус -Accepted (${TODAY}) - -## Контекст -<заполни, или «—» если архитектурных решений не было> - -## Решение -<заполни, или «—»> - -## Альтернативы -<заполни, или «—»> -EOF - echo "created: $ADR" -fi - -echo "" -echo "handoff: $HANDOFF" -echo "adr: $ADR" \ No newline at end of file diff --git a/.opencode/skills/get-project-map/SKILL.md b/.opencode/skills/get-project-map/SKILL.md index 0652b24..c1a0d1b 100644 --- a/.opencode/skills/get-project-map/SKILL.md +++ b/.opencode/skills/get-project-map/SKILL.md @@ -1,6 +1,6 @@ --- name: get-project-map -description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also contains a template for maintaining docs/project-map/. Also when user says "структура проекта", "project map", "дерево файлов". +description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also when user says "структура проекта", "project map", "дерево файлов". --- # Навык получения карты проекта (Project Map) @@ -18,54 +18,6 @@ description: Use when you need to view or update the current project folder/file 2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов. 3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала. -## Персистентная карта проекта (docs/project-map/) - -Помимо живого дерева через `repomix`, в репозитории может быть персистентная карта в `docs/project-map/`. Эта карта обновляется docs-reviewer агентом перед каждым code review. - -### Структура -- `docs/project-map/README.md` — индекс, общая структура, список модулей -- `docs/project-map/<module>.md` — один файл на модуль/директорию верхнего уровня - -### Шаблон MD-файла модуля - -```markdown ---- -module: <путь к модулю> -purpose: <назначение в одну строку> -key_files: - - <путь> — <роль> - - <путь> — <роль> -dependencies: [<зависимости>] -last_updated: <YYYY-MM-DD> ---- - -# <имя модуля> - -## Структура -- `<файл>` — <описание> -- `<файл>` — <описание> - -## Паттерны -- <используемые паттерны/конвенции> -``` - -### Что включать -- Структуру директорий (дерево модуля) -- Назначение модуля/директории -- Ключевые файлы и их роли -- Зависимости между модулями - -### Что НЕ включать -- Implementation details -- API signatures -- Внутреннюю логику - -### Когда обновлять -- Добавлены новые файлы или директории -- Удалены файлы или директории -- Переименованы файлы или директории -- Новые модули верхнего уровня - ## Handoff файлы (docs/handoff/) Контекст передаётся между сессиями через handoff-файлы — один файл на PR. diff --git a/.opencode/tools/_shared.ts b/.opencode/tools/_shared.ts index ddd2512..6a26641 100644 --- a/.opencode/tools/_shared.ts +++ b/.opencode/tools/_shared.ts @@ -6,8 +6,8 @@ import { spawnSync } from "child_process" * If `repo` is provided, returns `["--repo", repo]` (explicit target). * If omitted, returns `[]` so `gh` auto-detects the repo from its `cwd` * (set to `context.worktree` by `runGh`). Auto-detect is the default and - * backward-compatible behaviour for all 5 GitHub tools (create-issue, - * create-pr, post-review, post-docs-review, merge-pr) — see ADR-027. + * backward-compatible behaviour for all 4 GitHub tools (create-issue, + * create-pr, post-review, merge-pr) — see ADR-027. */ export function parseRepo(repo?: string): string[] { if (repo) return ["--repo", repo] diff --git a/.opencode/tools/post-docs-review.ts b/.opencode/tools/post-docs-review.ts deleted file mode 100644 index 01b16e8..0000000 --- a/.opencode/tools/post-docs-review.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { runGh } from "./_shared" - -const VERDICTS = ["APPROVE", "FIXED", "NO_CHANGES"] as const -type Verdict = typeof VERDICTS[number] - -export default tool({ - description: "Post docs review verdict as PR comment with deterministic heading. Docs-reviewer agent uses this instead of raw `gh pr comment` to guarantee `## Docs Review Summary` heading that pipeline-status.py parses.", - args: { - pr_number: tool.schema.number().describe("PR number to comment on"), - verdict: tool.schema.enum(VERDICTS).describe("Docs review verdict: APPROVE, FIXED, or NO_CHANGES"), - body: tool.schema.string().describe("Docs review body text (without heading — heading is auto-generated)"), - repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."), - }, - async execute(args, context) { - const comment = `## Docs 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 }) - if (r.status !== 0) { - return `⚠️ post-docs-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}` - } - return `Docs review posted on PR #${args.pr_number}: verdict=${args.verdict}` - }, -}) \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md deleted file mode 100644 index 28c9e01..0000000 --- a/docs/project-map/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# Project Map - -opencode-config — Docker-based AI coding assistant with persistent memory (opencode configuration). Runs in Docker via `docker-compose.yml` (dind + opencode services). - -Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-doctor/memory-save/memory-search/memory-list/memory-access/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback; `memory-setup` REMOVED PR#103) + `## Bug Discovery Protocol` (агенты создают issues через `create-issue` tool для найденных багов вне scope) + `## Linear Execution` (строго линейный pipeline, параллельные research OK, параллельное исполнение ЗАПРЕЩЕНО) — PR#113. Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63. - -## Structure - -``` -opencode-config/ -├── .github/ -│ ├── workflows/ -│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip); apt-get install ripgrep + npm ci (.opencode) для keyword search — PR#124 -│ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip) -│ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py) -│ └── dependabot.yml # pip + github-actions ecosystem updates -├── .opencode/ # Project-local opencode config (auto-discovery, zero env var) — PR#23 -│ ├── agents/ -│ │ ├── docs-reviewer.md # Docs validation subagent (project map + handoff + ADR, uses `commit`+`post-docs-review`+`create_issue` tools; `gh issue list*` allow) — PR#40, PR#46, PR#69, PR#113 -│ │ ├── memory-syncer.md # Distills gotchas from handoffs into opencode-memory (`create_issue` tool; `gh issue list*` allow) — PR#69, PR#113 -│ │ └── reviewer.md # Code review subagent (verdict via `post-review` tool: APPROVE|REQUEST_CHANGES|NEEDS_DISCUSSION) — PR#46, PR#69 -│ ├── commands/ -│ │ ├── configure-opencode.md # /configure-opencode — edit opencode.json -│ │ ├── cover.md # /cover — branded 1024×1024 cover generation (hardcoded Lime standard, agent: build, draw-image tool; no subtitle, extension via brand-logos) — PR#199 -│ │ ├── feature-spec.md # /feature-spec — SDD-style Q&A for feature planning (loads feature-spec skill) — PR#172 -│ │ ├── repo-readme.md # /repo-readme — standardized README generation (frontmatter agent: build, loads repo-readme skill) — PR#158 -│ │ ├── run-pipeline.md # /run-pipeline — 7-phase PR pipeline -│ │ └── spec.md # /spec — 9-phase spec generation -│ ├── skills/ -│ │ ├── add-skill/SKILL.md # Create new opencode skill -│ │ ├── bug-discovery/SKILL.md # Bug Discovery Protocol — create issue for out-of-scope bugs (create-issue tool, 7 SDD sections, duplicate check) — PR#120, PR#169 -│ │ ├── branch/SKILL.md # Branch naming conventions -│ │ ├── code-standards/SKILL.md # Universal code style rules -│ │ ├── configure-opencode/SKILL.md # Canonical rule: write to .opencode/ -│ │ ├── feature-spec/SKILL.md # SDD-style Q&A for feature planning (7 SDD sections, decision criteria, handoff to issue skill) — PR#172 -│ │ ├── get-project-map/SKILL.md # Maintain docs/project-map/ -│ │ ├── issue/SKILL.md # GitHub issue creation (7 SDD sections template) — PR#169 -│ │ ├── memory/SKILL.md # opencode-memory usage guide -│ │ ├── run-pipeline/SKILL.md # 7-phase pipeline orchestration -│ │ ├── python-development/SKILL.md # Python dev patterns -│ │ ├── release/SKILL.md # Tag + GitHub Release -│ │ ├── repo-init/SKILL.md # New repository bootstrap -│ │ ├── repo-readme/SKILL.md # Standardized README generation (create-readme tool: create vs validate, bilingual Why/What + Features table, GitHub metadata, quick_start_steps clickable steps + conditional bash block; tagline_en/tagline_ru required + delimiter-теги, breaking change note; workflow: create → draw-image (template "cover", slots icon/sub-icon/badge, brand-logos vs Lucide) → validate → fix-цикл) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#151, PR#158 (cover pipeline в workflow) -│ │ ├── tunnel/SKILL.md # Cloudflare tunnel toggle (tool `tunnel()`: 1-й вызов start, 2-й stop) — PR#63 (восстановлен, удалён в PR#42) -│ │ ├── run-tests/SKILL.md # Test runner guide -│ │ └── spec/SKILL.md # 9-phase spec generation (issue body template: 7 SDD sections) — PR#169 -│ ├── tools/ -│ │ ├── _shared.ts # Shared module for GitHub tools: parseRepo(repo?), runGh(args, repo?, opts?), formatResult(r, toolName) — PR#65 -│ │ ├── commit.ts # commit tool wrapper (1 arg message, validates format+staged) — PR#38 -│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates 7 SDD headings+format+labels; optional repo?: string) — PR#38, PR#65, PR#169 -│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65 -│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, include_clone/development_en/ru/quick_start_steps_en/ru optional params, clickable access_url [url](url), conditional bash block via hasBashBlock, RU heading 'Русский' + anchor checks, 6 delimiter pairs for slaid098.dev (summary-en/ru, features-en/ru, tagline-en/ru); tagline_en+tagline_ru required (BREAKING: tagline removed PR#151), content-валидация repo_name (lowercase kebab-case) + tagline_en (no Cyrillic) + tagline_ru (require Cyrillic), validateReadme H1 prefix check `# 🚀 `; generateReadme рендерит `![Cover](assets/cover.png)` после H1 перед tagline-разделителями; validateReadme substring-чек `assets/cover.png` (без file existence — для remote gh api режима); local fs path resolved via path.resolve(context.worktree, file_path) — overwrite existing README; remote gh api) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#149 (quick_start optional + guard), PR#151 (bilingual tagline + repo_name validation, breaking), PR#158 (cover image reference в template + validation), PR#175 (local file_path resolved against context.worktree — overwrite existing README) -│ │ ├── draw-image.ts # draw-image tool wrapper (opencode plugin, 5 args: template/title/subtitle?/slots?/out?; spawnSync node cli.ts render → sharp PNG) — PR#133 -│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65 -│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101 -│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2; npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124 -│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101 -│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101 -│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124 -│ │ ├── pipeline-status.ts # pipeline-status tool wrapper -│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65 -│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65 -│ │ ├── spec-status.ts # spec-status tool wrapper -│ │ ├── telegram-send.ts # telegram-send plugin-tool wrapper (spawnSync CLI .opencode/telegram/cli.ts; action: text|document|photo; MarkdownV2 default; error → ⚠️ telegram-send failed) — PR#174 -│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34 -│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133 -│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide -│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit -│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts -│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle) -│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out [--template-dir dir]; temp SVG в os.tmpdir()/draw-image-${pid}.svg + finally rmSync (race-safe для параллельных рендеров) — PR#178, PR#197 (--template-dir) -│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line) -│ │ ├── templates/cover.svg # 1024×1024 cover template (icon-слот 520×520 + sub-icon-слот 200×200 [восстановлен PR#201, был удалён PR#197] + {{title}} y=830; badge/subtitle удалены — PR#197, тесты на fixture) -│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles -│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall) -│ │ ├── brand-logos/ # brand SVG logos (opencode.svg — адаптированный логотип OpenCode 512×512 fill #ccff00, резолвится через draw-image slot icon=opencode) — PR#158 -│ │ ├── scripts/sync-lucide.mjs # postinstall: copy icons from node_modules/lucide-static → icons/lucide/ -│ │ ├── src/ -│ │ │ ├── config.ts # loadBrand + validateBrand (zod-style hex validation) -│ │ │ ├── slot-parser.ts # parseSlots: HTML comments → SlotSpec objects -│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox -│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs -│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg -│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) + loadTemplate(drawImageDir, name, templateDir?) — опц. templateDir (дефолт "templates", относит./абсолют.) — PR#197 -│ │ └── tests/ # vitest: 58 tests (unit + integration + e2e) — PR#178 (+3 cleanup), PR#197 (9 тестов на fixture, отвязка от продакшн cover.svg) -│ │ ├── fixtures/cover.svg # каноничный тестовый шаблон (все типы слотов: icon/sub-icon/badge/subtitle) — стабильный контракт для тестов, не зависит от продакшн templates/cover.svg — PR#197 -│ │ ├── helpers/fixtures.ts # loadFixture(name): читает из tests/fixtures/, экспортирует FIXTURES_DIR_ABS — PR#197 -│ │ ├── config.test.ts -│ │ ├── slot-parser.test.ts -│ │ ├── fit.test.ts -│ │ ├── recolor.test.ts -│ │ ├── cli.test.ts -│ │ ├── render.integration.test.ts # integration: PNG 1024x1024 + recursive out dir; renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197) -│ │ ├── idempotency.test.ts # на fixture (PR#197) -│ │ ├── slot.test.ts # на fixture (PR#197) -│ │ ├── e2e.test.ts # e2e: --template-dir tests/fixtures (PR#197) -│ │ ├── e2e.no-icon.test.ts # e2e: --template-dir tests/fixtures (PR#197) -│ │ ├── e2e.bad-input.test.ts -│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144); на fixture (PR#197) -│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197) -│ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.svg (PR#178); --template-dir tests/fixtures (PR#197) -│ │ └── cleanup.test.ts # unit: temp SVG cleanup — no .tmp-render.svg leftover, PID-based path under os.tmpdir, no stray .svg (PR#178); на fixture (PR#197) -│ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174 -│ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run -│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true -│ │ ├── vitest.config.ts # environment: node, include: tests/**/*.test.ts (e2e.manual.ts исключён — нет .test. в имени) -│ │ ├── cli.ts # argv-парсер, диспетчер по action (text/document/photo), parse_mode=MarkdownV2 по умолчанию; успех → JSON в stdout, провал → stderr + exit(1) -│ │ ├── src/ -│ │ │ ├── config.ts # loadConfig(overrides?): читает TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID из env, валидирует, override chatId приоритетнее env -│ │ │ ├── markdown.ts # escapeMarkdownV2(text): экранирует спецсимволы MarkdownV2 (_*[]()~`>#+-=|{}.!), кириллицу не трогает, backslash НЕ в наборе -│ │ │ └── api.ts # sendMessage/sendDocument/sendPhoto через встроенный fetch Node 22+; telegramFetch парсит result → {ok, message_id, chat_id}; ok:false → throw с description -│ │ └── tests/ # vitest: 39 unit-тестов (markdown 25, config 5, api 9), все проходят -│ │ ├── markdown.test.ts # 25 кейсов: пустая строка, без спецсимволов, кириллица, it.each по каждому спецсимволу, микс, backtick, `\` не в наборе -│ │ ├── config.test.ts # 5 кейсов: валидный env, отсутствие token/chatId, override приоритет, override без env -│ │ ├── api.test.ts # 9 кейсов: URL/method/body/headers sendMessage, parse_mode omit/plain/HTML, return value, Telegram API error, network error, sendDocument/sendPhoto FormData -│ │ └── e2e.manual.ts # ручной скрипт (НЕ в npm test — нет .test. в имени + vitest include) -│ ├── scripts/ -│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml) -│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml) -│ │ ├── observability.py # OTel spans for tools -│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard) -│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs -│ │ ├── spec-status.py # 9-phase spec oracle -│ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34 -│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools; `create_issue` enabled for reviewer/docs-reviewer/memory-syncer) — PR#40, PR#69, PR#113 (plugin block REMOVED PR#103, deprecated `new_api` provider REMOVED PR#106) -│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101 -│ └── .gitignore # Ignores node_modules, etc. -├── docs/ -│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md) -│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md) -│ └── project-map/ # This file — structure snapshot -├── assets/ # Generated cover images for README (cover.png + cover.meta.json idempotency hash; default draw-image out path, referenced by create-readme `![Cover](assets/cover.png)`) — PR#158 -├── src/ # Python RAG CLI (memory) — PR#17 -│ └── memory/ -│ ├── __init__.py -│ ├── __main__.py # Entry point for `python -m memory` -│ ├── cli.py # CLI commands (prog="memory") -│ ├── embedder.py # Embedding via OPENAI_BASE_URL (env-only, OpenAI-compatible); rate-limit-aware: Retry-After header parsing, retry=5, backoff max=30 — PR#83 -│ ├── index.py # Indexing with chunking (MEMORY_CHUNK_SIZE/OVERLAP env); SHA256 incremental index (.rag/meta.json), atomic writes (.tmp+os.replace), versioning (model:chunk_size:overlap), fcntl.flock(LOCK_EX) — PR#83 -│ └── search.py # Search with dedup by source in top-K -├── tests/ # pytest + TS/MJS test suite — PR#17 -│ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes; relative import inlining via inlineShared(); fs import support + multi-line type/var/non-null assertion stripping for create-readme.ts) — PR#38, PR#65, PR#175 -│ ├── test_agent_frontmatter.py # Agent frontmatter validators (no top-level doom_loop, permission.doom_loop present, steps:150) — PR#49, PR#69 -│ ├── test_check_adr_refs.py # adr-check.yml validator -│ ├── test_check_permissions.py # permissions-check.yml validator -│ ├── test_cli.py # src/memory/cli.py -│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) + opencode limits/init/healthcheck assertions (8 raw-text: 8G/4cpu/2048pids, init:true, healthcheck block+timing, restart-not-duplicated, dind-unchanged) — PR#51, PR#132 -│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103 -│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38 -│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38 -│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json; 7 SDD headings, 4 new missing-heading tests, +repo) — PR#38, PR#65, PR#169 -│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases, 7 SDD headings) — PR#38, PR#65, PR#169 -│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65 -│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65 -│ ├── test_create_readme_tool.py # .opencode/tools/create-readme.ts (via _ts_loader.mjs exec_stub_json; 14 tests: local create overwrite/new/subdir, remote 2-call, local validate, validation errors; regression #148 file_path resolved via context.worktree) — PR#175 -│ ├── test_draw_image_tool.py # .opencode/tools/draw-image.ts (via _ts_loader.mjs exec_stub_json; 5 tests: load/valid/failure/cwd/all-args) — PR#133 -│ ├── test_draw_image_tool.ts # TS wrapper test (mjs loader; 3 tests: valid/failure/cwd) — PR#133 -│ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL) -│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1) -│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip) -│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap) -│ ├── test_memory_doctor.ts # TS unit-тесты для memory-doctor (allGreen false при битом бинарнике G9, allGreen true regression guard, arch-mismatch detection G2, npm-missing hint G3) — PR#124 -│ ├── test_memory_keyword_search.ts # TS unit-тесты для resolveRgBinary (npm-package, system-fallback, null-nothing, null-no-fallback) + memory-search stderr warning при rgBin===null — PR#124 -│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only [БЕЗ RUN_LIVE], full hybrid, OpenRouter fallback, deletion [individual skipif(not RUN_LIVE)]; subprocess-based: ripgrep + python3 -m src.memory) — PR#102, PR#124 -│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65 -│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65 -│ ├── test_observability.py # .opencode/scripts/observability.py -│ ├── test_permissions.py # Global deny rules + agent.<name>.tools role-based access (8 tests) — PR#40, PR#69 -│ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching) -│ ├── test_pipeline_status_adr.py -│ ├── test_pipeline_status_ci.py -│ ├── test_pipeline_status_next_actions.py # NEXT_ACTIONS subagent_type+template per phase (25 tests) — PR#42 -│ ├── test_pipeline_status_project_map.py # check_project_map guard (README.md mandatory, ADR-028) — PR#67 -│ ├── test_pipeline_status_tool.py -│ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader) -│ ├── test_post_docs_review_tool.py # .opencode/tools/post-docs-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65 -│ ├── test_post_docs_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65 -│ ├── test_post_review_tool.py # .opencode/tools/post-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65 -│ ├── test_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65 -│ ├── test_search.py # src/memory/search.py -│ ├── test_spec_status.py # .opencode/scripts/spec-status.py -│ ├── test_spec_status_tool.py -│ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65 -│ └── test_tunnel_tool.ts # .opencode/tools/tunnel.ts (4 TS: spawnSync wiring, success/error/cwd) — PR#65 -├── pyproject.toml # Python project (uv, ruff, pytest config) -├── uv.lock # Locked deps for Python project -├── .pre-commit-config.yaml # ruff + UV hooks -├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids; command passed through entrypoint shim via "$@" — PR#24, PR#51, PR#132, PR#144 -├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search); ENTRYPOINT=/usr/local/bin/docker-entrypoint.sh (self-healing: npm ci if sharp missing) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124, PR#144 -├── docker-entrypoint.sh # Self-healing entrypoint shim: checks node_modules/sharp, runs npm ci if missing (continue-on-error), exec opencode "$@" — PR#144 -├── .dockerignore # Excludes app_data/, .git, **/node_modules from docker build context — PR#144 -│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory) -├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME), PR#174 (TELEGRAM_CHAT_ID), PR#199 (VERCEL_TOKEN placeholder, not wired) -├── app_data/ -│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36 -│ ├── workspaces/ # Agent working directory (.gitkeep) -│ └── ssh/ # SSH keys, not in git (.gitkeep) -├── .editorconfig -├── .gitignore -├── .python-version -├── AGENTS.md # Orchestrator directive (CRITICAL — overrides built-in prompt: FORBIDDEN delegation map explore/general/reviewer/docs-reviewer/memory-syncer + ALLOWED read-only oracle tools; global rules, Bug Discovery Protocol, Linear Execution; bind-mounted globally) — PR#31, PR#113, PR#189 -├── LICENSE -└── README.md -``` - -## Pending (future PRs) - -- `.opencode/scripts/pipeline-status.py` fix `610452f` — PR#7 (config/scripts/ migration) -- `.opencode/tools/` TS wrappers — PR#7 (config/tools/ migration) - -## Update Protocol - -Updated by docs-reviewer subagent on each PR. Reflects tracked files only (`git ls-files`). - -Guarded by `pipeline-status.py:check_project_map` — README.md existence is mandatory. diff --git a/tests/test_agent_frontmatter.py b/tests/test_agent_frontmatter.py index 13ba9d1..5580a01 100644 --- a/tests/test_agent_frontmatter.py +++ b/tests/test_agent_frontmatter.py @@ -1,14 +1,14 @@ """Tests for agent frontmatter validation — top-level vs permission-nested fields. Covers issue #48 acceptance criteria: -- ``reviewer.md``, ``docs-reviewer.md``, ``memory-syncer.md`` frontmatter does - NOT contain a top-level ``doom_loop`` field (outside the ``permission:`` block). +- ``reviewer.md`` and ``memory-syncer.md`` frontmatter does NOT contain a + top-level ``doom_loop`` field (outside the ``permission:`` block). A top-level ``doom_loop`` is invalid — it is not in the opencode ``AgentV2.Info`` schema and gets forwarded to the provider as a model param, causing ``AI_APICallError: Extra inputs are not permitted`` on strict providers. -- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 3 files +- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 2 files (valid — it is the real permission guard). -- ``steps: 150`` is present and unchanged in all 3 files. +- ``steps: 150`` is present and unchanged in all 2 files. PyYAML is not a project dependency, so frontmatter is parsed manually by splitting on ``---`` delimiters and tracking indentation (top-level fields have @@ -23,7 +23,6 @@ AGENTS_DIR = REPO_ROOT / ".opencode" / "agents" AGENT_FILES = [ AGENTS_DIR / "reviewer.md", - AGENTS_DIR / "docs-reviewer.md", AGENTS_DIR / "memory-syncer.md", ] @@ -81,7 +80,7 @@ def _parse_yaml_block(text: str) -> dict: def test_agent_files_exist(): - """All 3 agent files exist in .opencode/agents/.""" + """All 2 agent files exist in .opencode/agents/.""" for f in AGENT_FILES: assert f.exists(), f"agent file missing: {f}" @@ -90,7 +89,7 @@ def test_agent_files_exist(): def test_no_top_level_doom_loop(): - """Frontmatter of all 3 agents has NO top-level ``doom_loop`` key. + """Frontmatter of all 2 agents has NO top-level ``doom_loop`` key. A top-level ``doom_loop`` is invalid — outside ``permission:`` it is not a recognised agent field and gets forwarded to the provider. @@ -126,7 +125,7 @@ def test_no_top_level_doom_loop_raw(): def test_permission_doom_loop_present(): - """``doom_loop: deny`` INSIDE ``permission:`` is present in all 3 files. + """``doom_loop: deny`` INSIDE ``permission:`` is present in all 2 files. This is the valid permission guard — it must NOT be removed. """ @@ -147,7 +146,7 @@ def test_permission_doom_loop_present(): def test_steps_150_present(): - """``steps: 150`` is present in all 3 agent files (must not be changed).""" + """``steps: 150`` is present in all 2 agent files (must not be changed).""" for f in AGENT_FILES: fm = _parse_frontmatter(f) assert "steps" in fm, f"{f.name}: 'steps' field missing" @@ -158,7 +157,7 @@ def test_steps_150_present(): def test_frontmatter_parseable(): - """All 3 agent files have parseable frontmatter (split by ---).""" + """All 2 agent files have parseable frontmatter (split by ---).""" for f in AGENT_FILES: content = f.read_text() parts = content.split("---", 2) diff --git a/tests/test_check_adr_refs.py b/tests/test_check_adr_refs.py deleted file mode 100644 index 8f896b0..0000000 --- a/tests/test_check_adr_refs.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for .opencode/scripts/check-adr-refs.py — dangling ADR reference guard. - -Covers the deterministic ADR-ref guard: clean repo passes, a dangling -``ADR-999`` in a temp handoff is detected, self-reference inside an -``NNN-*.md`` file is excluded, a valid cross-ref (``ADR-002``) passes, -a file with no ADR refs passes, and ``node_modules/`` is excluded. - -Strategy mirrors ``tests/test_check_permissions.py``: -- ``test_clean_repo_passes`` runs the script as a subprocess on the - real (clean) repo — black-box, returncode 0, OK message in stdout. -- The remaining tests load the script in-process via importlib and - monkeypatch ``REPO_ROOT`` / ``ADR_DIR`` to point at a tmp_path tree, - then call ``main()`` and assert on captured stdout / SystemExit code. -""" - -import importlib.util -import subprocess -import sys -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "check-adr-refs.py" - - -def _load_script(): - spec = importlib.util.spec_from_file_location("check_adr_refs", SCRIPT_PATH) - module = importlib.util.module_from_spec(spec) - sys.modules["check_adr_refs"] = module - spec.loader.exec_module(module) - return module - - -car = _load_script() - - -# ── clean repo (subprocess black-box) ─────────────────────────────────────── - - -def test_clean_repo_passes(): - """On the current (clean) repo the script exits 0 with the OK message.""" - result = subprocess.run( - ["python3", str(SCRIPT_PATH)], - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert "OK: No dangling ADR references." in result.stdout - - -# ── helpers for in-process tests ──────────────────────────────────────────── - - -def _setup_tmp_repo(tmp_path: Path, monkeypatch) -> tuple[Path, Path]: - """Point the script's REPO_ROOT and ADR_DIR at a tmp_path tree. - - Creates the ``docs/decisions/`` dir and returns (repo_root, adr_dir). - """ - repo = tmp_path / "repo" - adr_dir = repo / "docs" / "decisions" - adr_dir.mkdir(parents=True) - handoff_dir = repo / "docs" / "handoff" - handoff_dir.mkdir(parents=True) - monkeypatch.setattr(car, "REPO_ROOT", repo) - monkeypatch.setattr(car, "ADR_DIR", adr_dir) - return repo, adr_dir - - -# ── dangling reference detected ───────────────────────────────────────────── - - -def test_dangling_ref_detected(tmp_path, monkeypatch, capsys): - """A handoff referencing ADR-999 (no 999-*.md) → exit 1 + ADR-999 in output.""" - repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) - (repo / "docs" / "handoff" / "pr-1-test.md").write_text("See ADR-999 for context.\n") - - with pytest.raises(SystemExit) as exc_info: - car.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ADR-999" in captured.out - assert "FAIL" in captured.out - - -# ── self-reference excluded ───────────────────────────────────────────────── - - -def test_self_reference_excluded(tmp_path, monkeypatch, capsys): - """An ADR file ``019-pr-94-test.md`` mentioning ADR-019 → passes (self-ref).""" - _repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) - (adr_dir / "019-pr-94-test.md").write_text("# ADR-019: self-reference is OK.\n") - - with pytest.raises(SystemExit) as exc_info: - car.main() - - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "OK: No dangling ADR references." in captured.out - - -# ── valid cross-reference passes ──────────────────────────────────────────── - - -def test_valid_cross_ref_passes(tmp_path, monkeypatch, capsys): - """A handoff referencing ADR-002 when 002-*.md exists → passes.""" - repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) - (adr_dir / "002-pr-56-pipeline-mandatory-adr.md").write_text("# ADR-002\n") - (repo / "docs" / "handoff" / "pr-3-test.md").write_text("Per ADR-002, ADR is mandatory.\n") - - with pytest.raises(SystemExit) as exc_info: - car.main() - - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "OK: No dangling ADR references." in captured.out - - -# ── no ADR refs in a file passes ──────────────────────────────────────────── - - -def test_no_adr_refs_passes(tmp_path, monkeypatch, capsys): - """A handoff with no ADR-NNN references → passes.""" - repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) - (repo / "docs" / "handoff" / "pr-4-test.md").write_text("No ADR references here.\n") - - with pytest.raises(SystemExit) as exc_info: - car.main() - - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "OK: No dangling ADR references." in captured.out - - -# ── node_modules excluded ─────────────────────────────────────────────────── - - -def test_exclude_node_modules(tmp_path, monkeypatch, capsys): - """A ``.md`` file under ``node_modules/`` with ADR-999 is NOT scanned.""" - repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) - nm = repo / "node_modules" / "some-pkg" - nm.mkdir(parents=True) - (nm / "README.md").write_text("Dangling ADR-999 should be ignored.\n") - - with pytest.raises(SystemExit) as exc_info: - car.main() - - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "OK: No dangling ADR references." in captured.out - assert "ADR-999" not in captured.out - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 025b15f..1d70213 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -145,18 +145,6 @@ def test_reviewer_tools_readonly(): assert tools["merge_pr"] is False -# ── agent.docs-reviewer.tools ─────────────────────────────────────────────── - - -def test_docs_reviewer_tools(): - """docs-reviewer: commit=true (project map/handoff), create_issue=true, rest false.""" - tools = _load_config()["agent"]["docs-reviewer"]["tools"] - assert tools["commit"] is True - assert tools["create_pr"] is False - assert tools["create_issue"] is True - assert tools["merge_pr"] is False - - # ── agent.memory-syncer.tools ─────────────────────────────────────────────── diff --git a/tests/test_post_docs_review_tool.py b/tests/test_post_docs_review_tool.py deleted file mode 100644 index 8b4457c..0000000 --- a/tests/test_post_docs_review_tool.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Tests for .opencode/tools/post-docs-review.ts — the post-docs-review custom tool. - -Mirrors tests/test_commit_tool.py / test_create_pr_tool.py: -exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` -using the ``exec_stub_json`` mode (multi-arg tools). - -The loader is parameterized via the ``TS_FILE`` env var. These tests set -``TS_FILE=.opencode/tools/post-docs-review.ts``. - -Modes used: -- ``load`` — sanity-check that the tool loads and declares pr_number, verdict, - body args. -- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: - (a) success path: valid verdict (APPROVE/FIXED/NO_CHANGES) - → "Docs review posted on PR #N: verdict=<V>", - (b) comment heading: body passed to gh contains "## Docs Review Summary", - (c) comment verdict: body passed to gh contains "### Verdict: <V>", - (d) spawnSync args: gh pr comment N --body <comment> (no --repo; gh auto-detects - from context.worktree — ADR-019 superseding note, PR#60). - -post-docs-review.ts makes 1 spawnSync call (gh pr comment) on all paths. - -Note on enum validation: see test_post_review_tool.py — same delegation to -zod schema, execute() itself is permissive. -""" - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" -TS_FILE = REPO_ROOT / ".opencode" / "tools" / "post-docs-review.ts" -TS_FILE_REL = ".opencode/tools/post-docs-review.ts" - -COMMENT_URL = "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1" -COMMENT_OK_RESPONSE = {"status": 0, "stdout": COMMENT_URL + "\n", "stderr": ""} -EXPECTED_APPROVE = "Docs review posted on PR #45: verdict=APPROVE" -EXPECTED_FIXED = "Docs review posted on PR #45: verdict=FIXED" -EXPECTED_NO_CHANGES = "Docs review posted on PR #45: verdict=NO_CHANGES" -EXPECTED_APROVE_TYPO = "Docs review posted on PR #45: verdict=APROVE" - - -def _run_loader(*args: str) -> dict: - """Invoke the loader with TS_FILE env set to post-docs-review.ts and parse JSON stdout.""" - env = {**os.environ, "TS_FILE": TS_FILE_REL} - proc = subprocess.run( - ["node", str(LOADER), *args], - capture_output=True, - text=True, - check=False, - cwd=str(REPO_ROOT), - timeout=60, - env=env, - ) - if proc.returncode != 0: - raise RuntimeError( - f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" - f"stdout: {proc.stdout}\nstderr: {proc.stderr}" - ) - return json.loads(proc.stdout) - - -def _run_exec(args: dict, responses: list[dict]) -> dict: - """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" - return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) - - -def test_loader_can_load_tool(): - """Sanity: post-docs-review.ts loads and declares pr_number, verdict, body args.""" - if not TS_FILE.exists(): - pytest.skip("post-docs-review.ts not present") - out = _run_loader("load") - assert "description" in out - args = out["args"] - assert "pr_number" in args, f"missing pr_number arg: {args}" - assert "verdict" in args, f"missing verdict arg: {args}" - assert "body" in args, f"missing body arg: {args}" - - -def test_valid_approve(): - """execute() with APPROVE verdict returns 'Docs review posted on PR #N: verdict=APPROVE'.""" - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "- Handoff: valid"}, - [COMMENT_OK_RESPONSE], - ) - result = out["result"] - assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}" - - -def test_valid_fixed(): - """execute() with FIXED verdict returns success.""" - out = _run_exec( - {"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed: added section"}, - [COMMENT_OK_RESPONSE], - ) - result = out["result"] - assert result == EXPECTED_FIXED, f"expected success, got: {result!r}" - - -def test_valid_no_changes(): - """execute() with NO_CHANGES verdict returns success.""" - out = _run_exec( - {"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No structural changes"}, - [COMMENT_OK_RESPONSE], - ) - result = out["result"] - assert result == EXPECTED_NO_CHANGES, f"expected success, got: {result!r}" - - -def test_invalid_verdict_not_validated_by_execute(): - """execute() does NOT validate verdict inline — zod enum does at opencode layer. - - The _ts_loader.mjs shim does not validate (chainable builder without checks). - Passing a typo "APROVE" (missing a P) builds a comment with the bad verdict. - The real guard is tool.schema.enum(VERDICTS) in the opencode runtime. - This test documents that execute() itself is permissive — validation is - delegated to the zod schema, not duplicated inside execute(). - """ - out = _run_exec( - {"pr_number": 45, "verdict": "APROVE", "body": "typo verdict"}, - [COMMENT_OK_RESPONSE], - ) - result = out["result"] - assert result == EXPECTED_APROVE_TYPO, f"expected permissive, got: {result!r}" - - -def test_comment_has_heading(): - """Comment body passed to gh contains '## Docs Review Summary' heading.""" - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" - args = calls[0]["args"] - body_idx = args.index("--body") + 1 - comment = args[body_idx] - assert comment.startswith("## Docs Review Summary\n"), ( - f"expected heading at start, got: {comment!r}" - ) - - -def test_comment_has_verdict(): - """Comment body passed to gh contains '### Verdict: <verdict>'.""" - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - args = calls[0]["args"] - body_idx = args.index("--body") + 1 - comment = args[body_idx] - assert "### Verdict: APPROVE" in comment, f"expected verdict line, got: {comment!r}" - - -def test_comment_has_fixed_verdict(): - """Comment body with FIXED contains '### Verdict: FIXED'.""" - out = _run_exec( - {"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed"}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - args = calls[0]["args"] - body_idx = args.index("--body") + 1 - comment = args[body_idx] - assert "### Verdict: FIXED" in comment, f"expected verdict line, got: {comment!r}" - - -def test_comment_has_no_changes_verdict(): - """Comment body with NO_CHANGES contains '### Verdict: NO_CHANGES'.""" - out = _run_exec( - {"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No changes"}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - args = calls[0]["args"] - body_idx = args.index("--body") + 1 - comment = args[body_idx] - assert "### Verdict: NO_CHANGES" in comment, f"expected verdict line, got: {comment!r}" - - -def test_spawnsync_args(): - """spawnSync called with gh pr comment <N> --body <comment> (no --repo). - - --repo was hardcoded to slaid098/opencode-config (ADR-019) and caused silent - misroute to the wrong repo when working outside opencode-config. Removed in - PR#60 — gh now auto-detects the repo from context.worktree (cwd), matching - create-pr.ts/merge-pr.ts. Symmetric with the success-path assertions. - """ - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" - call = calls[0] - assert call["cmd"] == "gh", f"expected cmd 'gh', got: {call['cmd']!r}" - args = call["args"] - assert args[0] == "pr", f"expected first arg 'pr', got: {args[0]!r}" - assert args[1] == "comment", f"expected second arg 'comment', got: {args[1]!r}" - assert args[2] == "45", f"expected PR number '45', got: {args[2]!r}" - assert "--body" in args, "missing --body flag" - # --repo MUST NOT be present — gh auto-detects from cwd (context.worktree). - # Hardcoded --repo caused silent misroute (PR#60 root cause). - assert "--repo" not in args, ( - f"--repo must not be hardcoded; gh auto-detects from cwd. args: {args!r}" - ) - # args must end with --body <comment> (no trailing --repo slaid098/...). - assert args[-2] == "--body", f"expected args to end with --body <comment>, got: {args[-2:]!r}" - assert args[-1].startswith("## Docs Review Summary\n"), ( - f"expected last arg to be the comment body, got: {args[-1]!r}" - ) - - -def test_execute_uses_cwd_from_context(): - """execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).""" - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" - opts = calls[0]["opts"] - assert opts is not None, "spawnSync called without opts — expected cwd kwarg" - assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" - assert opts["cwd"] == str(REPO_ROOT), ( - f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" - ) - - -def test_gh_failure_returns_error(): - """execute() with gh exit non-zero returns error message with exit code.""" - fail_response = {"status": 1, "stdout": "", "stderr": "gh: not authenticated"} - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [fail_response], - ) - result = out["result"] - assert "post-docs-review failed" in result, f"expected failure message, got: {result!r}" - assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}" - - -def test_repo_explicit_passed_to_gh(): - """execute() with repo='foo/bar' → gh receives '--repo foo/bar' before the subcommand. - - The shared runGh helper prepends ['--repo', <repo>] to the gh argv so an - explicit repo targets the right owner/name regardless of context.worktree. - """ - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "foo/bar"}, - [COMMENT_OK_RESPONSE], - ) - result = out["result"] - assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}" - calls = out["calls"] - assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}" - args = calls[0]["args"] - assert args[0] == "--repo", f"expected --repo first, got: {args[0]!r}" - assert args[1] == "foo/bar", f"expected repo value, got: {args[1]!r}" - assert args[2] == "pr", f"expected 'pr' after --repo <name>, got: {args[2]!r}" - - -def test_repo_omitted_no_repo_flag(): - """execute() without repo → gh argv has NO --repo (auto-detect from cwd). - - Backward-compatibility: when repo is omitted, runGh returns [] from - parseRepo, so gh auto-detects the repo from context.worktree (cwd) — - matching the pre-refactor behaviour (ADR-025 / PR#61). This complements - test_spawnsync_args which asserts the same on the success path. - """ - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."}, - [COMMENT_OK_RESPONSE], - ) - calls = out["calls"] - assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}" - args = calls[0]["args"] - assert "--repo" not in args, ( - f"--repo must NOT be added when repo arg omitted; gh auto-detects. args: {args!r}" - ) - assert args[0] == "pr", f"expected 'pr' first, got: {args[0]!r}" - - -def test_repo_invalid_gh_error(): - """execute() with invalid repo + gh failure → error mentions post-docs-review + exit code. - - gh rejects an invalid owner/name with non-zero exit; the tool formats the - error with its tool-specific message (post-docs-review failed for PR #N). - """ - fail_response = {"status": 1, "stdout": "", "stderr": 'expected the "owner/repo" format'} - out = _run_exec( - {"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "not-a-valid-repo"}, - [fail_response], - ) - result = out["result"] - assert "post-docs-review failed" in result, f"expected tool failure, got: {result!r}" - assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}" - calls = out["calls"] - args = calls[0]["args"] - assert "--repo" in args, f"expected --repo in args even on failure, got: {args!r}" diff --git a/tests/test_post_docs_review_tool.ts b/tests/test_post_docs_review_tool.ts deleted file mode 100644 index d47de0c..0000000 --- a/tests/test_post_docs_review_tool.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Tests for .opencode/tools/post-docs-review.ts — the post-docs-review custom tool. - * - * Mirror of tests/test_post_review_tool.ts / test_commit_tool.ts: - * the tool is a spawnSync wrapper around `gh pr comment` with verdict enum - * validation and deterministic comment heading generation. - * - * Runtime note: opencode ships a standalone binary with Bun bundled inside; - * there is no separate `bun` CLI on the host (CI runner uses node + pytest). - * The CI runs the equivalent Python tests in tests/test_post_docs_review_tool.py - * via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg - * tools). This file documents the intended TS-side test cases and is - * runnable under `bun test` once a bun runtime is available on the host. - * - * Test cases (mirror tests/test_post_docs_review_tool.py): - * - test_valid_approve — valid APPROVE verdict → "Docs review posted" - * - test_valid_fixed — valid FIXED verdict → "Docs review posted" - * - test_valid_no_changes — valid NO_CHANGES verdict → "Docs review posted" - * - test_invalid_verdict — invalid verdict "APROVE" (typo) → error - * - test_comment_has_heading — comment body contains "## Docs Review Summary" - * - test_comment_has_verdict — comment body contains "### Verdict: <verdict>" - * - test_spawnsync_args — spawnSync called with correct gh args - */ - -import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } -import { spawnSync } from "child_process" -import path from "path" - -const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "post-docs-review.ts") - -function ctx() { - return { - sessionID: "t", messageID: "t", agent: "t", - directory: ".", worktree: ".", - abort: new AbortController().signal, - metadata() {}, async ask() {}, - } -} - -const COMMENT_OK = { status: 0, stdout: "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1\n", stderr: "" } - -describe("post-docs-review tool", () => { - test("test_valid_approve — APPROVE verdict succeeds", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - const result = await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "- Project map: no structural changes\n- Handoff: valid", - }, ctx()) - expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE") - const bodyIdx = capturedArgs.indexOf("--body") + 1 - expect(capturedArgs[bodyIdx]).toContain("## Docs Review Summary") - expect(capturedArgs[bodyIdx]).toContain("### Verdict: APPROVE") - }) - - test("test_valid_fixed — FIXED verdict succeeds", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - const result = await mod.default.execute({ - pr_number: 45, - verdict: "FIXED", - body: "- Handoff: fixed: added missing section", - }, ctx()) - expect(result).toBe("Docs review posted on PR #45: verdict=FIXED") - const bodyIdx = capturedArgs.indexOf("--body") + 1 - expect(capturedArgs[bodyIdx]).toContain("### Verdict: FIXED") - }) - - test("test_valid_no_changes — NO_CHANGES verdict succeeds", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - const result = await mod.default.execute({ - pr_number: 45, - verdict: "NO_CHANGES", - body: "- Project map: no structural changes\n- Handoff: valid", - }, ctx()) - expect(result).toBe("Docs review posted on PR #45: verdict=NO_CHANGES") - const bodyIdx = capturedArgs.indexOf("--body") + 1 - expect(capturedArgs[bodyIdx]).toContain("### Verdict: NO_CHANGES") - }) - - test("test_invalid_verdict — typo 'APROVE' → error", async () => { - mock.module("child_process", () => ({ - spawnSync: () => COMMENT_OK, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - // Invalid verdict passed directly — at runtime zod would reject this, - // but the loader shim does no validation. The tool builds the comment - // regardless. The enum validation happens at the opencode layer (zod), - // not inside execute(). This test documents that the tool itself does - // not validate verdicts (delegated to zod schema). - const result = await mod.default.execute({ - pr_number: 45, - verdict: "APROVE", - body: "typo verdict", - }, ctx()) - expect(result).toBe("Docs review posted on PR #45: verdict=APROVE") - }) - - test("test_comment_has_heading — comment body contains '## Docs Review Summary'", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - }, ctx()) - const bodyIdx = capturedArgs.indexOf("--body") + 1 - const comment = capturedArgs[bodyIdx] - expect(comment.startsWith("## Docs Review Summary\n")).toBe(true) - }) - - test("test_comment_has_verdict — comment body contains '### Verdict: APPROVE'", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - }, ctx()) - const bodyIdx = capturedArgs.indexOf("--body") + 1 - const comment = capturedArgs[bodyIdx] - expect(comment).toContain("### Verdict: APPROVE") - }) - - test("test_spawnsync_args — spawnSync called with correct gh args (no --repo)", async () => { - let capturedCmd - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (cmd, args) => { - capturedCmd = cmd - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - }, ctx()) - expect(capturedCmd).toBe("gh") - expect(capturedArgs[0]).toBe("pr") - expect(capturedArgs[1]).toBe("comment") - expect(capturedArgs[2]).toBe("45") - expect(capturedArgs).toContain("--body") - // --repo MUST NOT be present — gh auto-detects from cwd (PR#60). - expect(capturedArgs).not.toContain("--repo") - // args end with --body <comment> (no trailing --repo slaid098/...). - const lastIdx = capturedArgs.length - 1 - expect(capturedArgs[lastIdx - 1]).toBe("--body") - expect(capturedArgs[lastIdx].startsWith("## Docs Review Summary\n")).toBe(true) - }) - - test("test_repo_explicit — repo='foo/bar' prepends --repo to gh argv", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - const result = await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - repo: "foo/bar", - }, ctx()) - expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE") - expect(capturedArgs[0]).toBe("--repo") - expect(capturedArgs[1]).toBe("foo/bar") - expect(capturedArgs[2]).toBe("pr") - }) - - test("test_repo_omitted — no --repo flag (auto-detect from cwd)", async () => { - let capturedArgs - mock.module("child_process", () => ({ - spawnSync: (_cmd, args) => { - capturedArgs = args - return COMMENT_OK - }, - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - }, ctx()) - // Backward-compat: --repo MUST NOT be present when repo arg omitted. - expect(capturedArgs).not.toContain("--repo") - expect(capturedArgs[0]).toBe("pr") - }) - - test("test_repo_invalid — invalid repo + gh failure → tool error", async () => { - mock.module("child_process", () => ({ - spawnSync: () => ({ status: 1, stdout: "", stderr: 'expected the "owner/repo" format' }), - })) - const mod = await import(TOOL_SRC + "?t=" + Date.now()) - const result = await mod.default.execute({ - pr_number: 45, - verdict: "APPROVE", - body: "Docs body", - repo: "not-a-valid-repo", - }, ctx()) - expect(result).toContain("post-docs-review failed") - expect(result).toContain("exit 1") - }) -}) \ No newline at end of file