diff --git a/.opencode/skills/repo-readme/SKILL.md b/.opencode/skills/repo-readme/SKILL.md index 7bc8ace..586cd10 100644 --- a/.opencode/skills/repo-readme/SKILL.md +++ b/.opencode/skills/repo-readme/SKILL.md @@ -50,7 +50,9 @@ Support block, Quick Start, language switcher). Скилл даёт контек пройдёт. Локальный режим (по умолчанию): тулза пишет в `file_path` (default -`README.md`) через `fs.writeFileSync`. Удалённый режим: передай `repo` +`README.md`) через `fs.writeFileSync`, путь резолвится относительно +рабочей директории сессии (`context.worktree`) — существующий файл +перезаписывается. Удалённый режим: передай `repo` (`owner/name`) — тулза сделает PUT через `gh api repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA. diff --git a/.opencode/tools/create-readme.ts b/.opencode/tools/create-readme.ts index c91d2f2..9fcb68c 100644 --- a/.opencode/tools/create-readme.ts +++ b/.opencode/tools/create-readme.ts @@ -1,5 +1,6 @@ import { spawnSync } from "child_process" import { readFileSync, writeFileSync } from "fs" +import path from "path" import { tool } from "@opencode-ai/plugin" type Feature = { emoji: string; name: string; description: string } @@ -310,6 +311,7 @@ export default tool({ async execute(args, context) { try { const file_path = args.file_path ?? "README.md" + const absPath = path.resolve(context.worktree, file_path) if (args.mode === "create") { const required: Record = { @@ -401,7 +403,7 @@ export default tool({ return `README.md updated in ${args.repo} via GitHub API` } - writeFileSync(file_path, content, "utf-8") + writeFileSync(absPath, content, "utf-8") return `README.md created at ${file_path}` } @@ -418,7 +420,7 @@ export default tool({ const data = JSON.parse(getRes.stdout) content = Buffer.from(data.content, "base64").toString("utf-8") } else { - content = readFileSync(file_path, "utf-8") + content = readFileSync(absPath, "utf-8") } const result = validateReadme(content) diff --git a/docs/decisions/073-pr-175-overwrite-existing-local-readme.md b/docs/decisions/073-pr-175-overwrite-existing-local-readme.md new file mode 100644 index 0000000..91ab621 --- /dev/null +++ b/docs/decisions/073-pr-175-overwrite-existing-local-readme.md @@ -0,0 +1,52 @@ +# ADR-073: create-readme local-mode path resolution via context.worktree + +## Статус +Accepted (2026-07-31) + +## Контекст + +Тулза `create-readme` (`.opencode/tools/create-readme.ts`) работает в двух +режимах: локальном (`file_path`, без `repo`) и удалённом (`repo: owner/name` +через `gh api`). В удалённом режиме `spawnSync` вызовы корректно передавали +`cwd: context.worktree` — рабочую директорию сессии пользователя. В локальном +режиме `writeFileSync(file_path, ...)` / `readFileSync(file_path, ...)` +вызывались с относительным `file_path` (default `README.md`), который +резолвился относительно **CWD процесса плагина Bun**, а не относительно +`context.worktree`. В продакшене CWD плагина ≠ рабочая директория +пользователя — файл писался/читался не туда, отчёт `README.md created at +README.md` был ложным success (issue #148). + +Асимметрия: `spawnSync` получал `cwd`, а `fs.*Sync` — нет. Тестовый лоадер +`tests/_ts_loader.mjs` не поддерживал `import { readFileSync, writeFileSync } +from "fs"` и расширенный TS-синтаксис (многострочные type-алиасы, non-null +assertions, type annotations в переменных) — локальный режим тулзы нельзя +было протестировать. + +## Решение + +1. **Резолв пути относительно worktree**: `const absPath = + path.resolve(context.worktree, file_path)` — симметрично с `spawnSync` + в удалённом режиме. `writeFileSync(absPath, ...)` / `readFileSync(absPath, + ...)` работают с абсолютным путём. Существующий файл гарантированно + перезаписывается. + +2. **Расширение `_ts_loader.mjs`**: `stripTs` теперь обрабатывает `fs` + импорты, многострочные `type X = { ... }`, type annotations в + `const/let/var`, non-null assertions `x!`. `loadTool(spawnSyncImpl, + fsImpl)` передаёт `fs` модуль в sandbox. Это открывает возможность + тестировать любую тулзу, использующую `fs` в локальном режиме. + +## Альтернативы + +- **`process.chdir(context.worktree)` в начале `execute()`**: меняло бы CWD + процесса, но побочные эффекты на другие тулзы в том же процессе + неприемлемы (гонки, неявное состояние). Отвергнуто. + +- **Передача `file_path` как абсолютного всегда (валидировать на входе)**: + ломало бы существующие вызовы с относительным `file_path` (default + `README.md`). `path.resolve(worktree, file_path)` прозрачно обрабатывает + оба случая (относительный → относительно worktree; абсолютный → как есть). + +- **Не расширять лоадер, тестировать только через интеграцию**: медленнее, + хрупче, не ловит регрессии на уровне `execute()`. Расширение лоадера + позволяет точечные unit-тесты с реальным `fs` во временных директориях. \ No newline at end of file diff --git a/docs/handoff/pr-175-overwrite-existing-local-readme.md b/docs/handoff/pr-175-overwrite-existing-local-readme.md new file mode 100644 index 0000000..dd4ff92 --- /dev/null +++ b/docs/handoff/pr-175-overwrite-existing-local-readme.md @@ -0,0 +1,108 @@ +--- +pr: 175 +title: fix(repo-readme): create mode does not overwrite existing local README +--- + +## Что сделано + +Фикс бага #148 в тулзе `create-readme` (`.opencode/tools/create-readme.ts`): +локальный режим (`file_path`, без `repo`) вызывал `writeFileSync(file_path, ...)` +и `readFileSync(file_path, ...)` с относительным `file_path` (default +`README.md`), который резолвился относительно CWD процесса плагина Bun, а НЕ +относительно `context.worktree` (рабочей директории сессии пользователя). +Результат: отчёт `README.md created at README.md` — ложный success, файл на +диске не менялся. + +Root cause: `spawnSync` вызовы в удалённом режиме корректно передавали +`cwd: context.worktree`, но `fs.*Sync` в локальном режиме — нет, пути +резолвились не там. + +Изменения: + +- `.opencode/tools/create-readme.ts`: + - Добавлен `import path from "path"`. + - `absPath = path.resolve(context.worktree, file_path)` — путь резолвится + относительно рабочей директории сессии. + - `writeFileSync(absPath, ...)` и `readFileSync(absPath, ...)` — запись/чтение + в/из корректной директории. Существующий README гарантированно + перезаписывается (создаётся новый если не существует). + - Удалённый режим (`args.repo`) не тронут — уже использовал + `cwd: context.worktree` для `spawnSync`. + +- `tests/_ts_loader.mjs` — расширение лоадера для тестирования `create-readme.ts` + (локальный режим использует `fs`): + - `stripTs`: обработка `import { readFileSync, writeFileSync } from "fs"` → + `const { ... } = require("fs")`. + - `stripTs`: `g`-флаг для стрипа `type X = ...` (несколько алиасов в одном + файле: `Feature`, `CustomSection`, `CreateArgs`). + - `stripTs`: стрип многострочных `type X = { ... }` object-типов. + - `stripTs`: обобщение стрипа return type (объектный литерал как return type + `validateReadme(...): { ok: boolean; issues: string[] }`). + - `stripTs`: стрип type annotations в `const/let/var x: Type = ...`. + - `stripTs`: стрип TS non-null assertions `x!` → `x` (не трогает `!=`, `!==`, + унарный `!foo`). + - `loadTool(spawnSyncImpl, fsImpl)`: `fs` передаётся в sandbox через `new + Function(..., "fs", ...)` и `require("fs")` shim возвращает модуль + (настоящий `node:fs` по умолчанию или mock). + +- `tests/test_create_readme_tool.py` — новый файл, 14 регрессионных тестов: + - `test_loader_can_load_tool` — sanity (args declared). + - `test_local_create_overwrites_existing_readme` — регрессия #148: существующий + README перезаписывается (mtime обновляется, delimiter-теги присутствуют, + старый контент отсутствует). + - `test_local_create_creates_new_readme` — создаёт новый файл. + - `test_local_create_custom_subdir_file_path` — пишет в поддиректорию. + - `test_remote_create_makes_two_spawnsync_calls` — удалённый режим: 2 вызова + (GET sha + PUT), не сломан фиксом. + - `test_remote_create_passes_worktree_cwd` — `cwd=context.worktree` в opts. + - `test_local_validate_reads_file_path` — локальный validate читает из + `file_path`. + - `test_local_validate_reports_missing_delimiters` — malformed README → issues. + - 6 валидационных ошибок: missing repo_name, missing tagline_ru, uppercase + repo_name, latin tagline_ru, cyrillic tagline_en, empty features_en. + +- `.opencode/skills/repo-readme/SKILL.md` — раздел 3: уточнение, что `file_path` + резолвится относительно `context.worktree` и существующий файл + перезаписывается. + +- `docs/project-map/README.md` — обновлены описания `create-readme.ts`, + `_ts_loader.mjs`, добавлен `test_create_readme_tool.py`. + +## Почему + +Issue #148: `create-readme` (mode `create`, локальный режим, без `repo`) +возвращал `README.md created at README.md`, но файл на диске не изменялся — +`stat` показывал прежний mtime, delimiter-теги отсутствовали. Скилл +`repo-readme` (раздел 6) обещает «перезапишет README.md (локально) через +`fs.writeFileSync`» — фактически ложный success. Тот же root cause затрагивал +`validate` (читал из неверного пути, не находил файл → ошибка вместо +валидации реального README). + +Фикс `path.resolve(context.worktree, file_path)` гарантирует, что путь +резолвится относительно рабочей директории сессии (как и `spawnSync` в +удалённом режиме), а не относительно CWD процесса плагина. Перешёл от +«relative path против CWD процесса» к «absolute path относительно worktree» — +симметрично с тем, как `spawnSync` получает `cwd`. + +## Pending + +— + +## Watch out + +Лоадер `tests/_ts_loader.mjs` расширен для поддержки `fs` и расширенного +TS-стриппинга (многострочные type-алиасы, non-null assertions, type +annotations в переменных). Эти расширения безопасны для существующих тулз +(проверено: `commit.ts`, `draw-image.ts`, `create-pr.ts`, +`create-issue.ts` — все загружаются и 459 тестов проходят). Но если будущая +тулза использует более сложный TS-синтаксис (generics в вызовах, conditional +types, template literal types), лоадер может потребовать дальнейшего +расширения `stripTs`. + +Тесты локального режима используют абсолютный `file_path` во временной +директории (`tempfile.TemporaryDirectory`), чтобы избежать перезаписи +README репозитория (лоадер хардкодит `worktree: REPO_ROOT`, относительный +путь `README.md` резолвился бы в `REPO_ROOT/README.md`). Для полноценного +теста `path.resolve(context.worktree, relative)` потребовалось бы параметризовать +`worktree` в лоадере — оставлено как future improvement (зафиксировано в +комментарии теста). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index a47692d..1973316 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -48,7 +48,7 @@ opencode-config/ │ │ ├── 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 + 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) +│ │ ├── 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 @@ -135,7 +135,7 @@ opencode-config/ │ ├── 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()) — PR#38, PR#65 +│ ├── _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 @@ -148,6 +148,7 @@ opencode-config/ │ ├── 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) diff --git a/tests/_ts_loader.mjs b/tests/_ts_loader.mjs index 4c1b807..d5cf1da 100644 --- a/tests/_ts_loader.mjs +++ b/tests/_ts_loader.mjs @@ -21,6 +21,7 @@ // used by the integration test against the real pipeline-status.py. import { readFileSync } from "node:fs" +import * as nodeFs from "node:fs" import { fileURLToPath } from "node:url" import path from "node:path" import { spawnSync } from "node:child_process" @@ -90,21 +91,32 @@ function stripTs(src) { out = out.replace(/^import\s+\{\s*spawnSync\s*\}\s+from\s+["']child_process["'];?\s*$/m, 'const { spawnSync } = require("child_process");') out = out.replace(/^import\s+path\s+from\s+["']path["'];?\s*$/m, 'const path = require("path");') out = out.replace(/^import\s+\{\s*tool\s*\}\s+from\s+["']@opencode-ai\/plugin["'];?\s*$/m, 'const tool = (x) => x;') + // fs imports (create-readme.ts uses readFileSync/writeFileSync in local mode). + // Convert to CJS require so the sandbox can inject the fs module. + out = out.replace(/^import\s+\{\s*([^}]+)\s*\}\s+from\s+["']fs["'];?\s*$/m, 'const { $1 } = require("fs");') // Strip relative imports (`./_shared`, `../foo`, etc.) — loadTool inlines them. out = out.replace(/^import\s+\{[^}]*\}\s+from\s+["']\.[^"']+["'];?\s*$/m, "") // Replace `import.meta.dir` with the directory of the TS file. out = out.replace(/import\.meta\.dir/g, JSON.stringify(path.dirname(TS_FILE))) // Strip `as const` assertions: `["APPROVE", ...] as const` -> `["APPROVE", ...]` out = out.replace(/\bas\s+const\b/g, "") - // Strip `type = ...` type alias declarations (single-line, optional `;`). - out = out.replace(/^type\s+\w+\s*=\s*.+$\s*$/m, "") + // Strip `type = ...` type alias declarations. Covers both single-line + // (`type Foo = string`) and multi-line object types (`type Bar = {\n a: T\n b?: U\n}`) + // used by create-readme.ts (Feature, CustomSection, CreateArgs). Use `gs` + // (`.` matches newlines) for the multi-line case anchored at `^type ... = {` + // through the closing `}` on its own line. + out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "") + out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "") // Strip `export ` keyword on top-level declarations (shared modules). out = out.replace(/^export\s+(function|const|let|var)\b/gm, "$1") // Strip type annotations on function params + return type: // `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {` // Handles single-line function signatures (used by _shared.ts). Object // types like `{ cwd?: string }` are matched non-greedily within a param. - out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*[^{]+)?\s*\{/gm, (line, head, params, _ret) => { + // Return type may itself be an object literal type (e.g. create-readme.ts + // `validateReadme(content: string): { ok: boolean; issues: string[] }`), + // so match greedily from `):` up to the final ` {` that opens the body. + out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => { const cleaned = params .split(",") .map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim()) @@ -112,6 +124,18 @@ function stripTs(src) { .join(", ") return `${head}${cleaned}) {` }) + // Strip type annotations on variable declarations: + // `const issues: string[] = []` -> `const issues = []` + // `let content: string` -> `let content` + // Handles common TS types (string, boolean, number, arrays, generics like + // Record<...>, union types `A | B`, and `undefined`). Matches the `: ` + // segment between the binding name and `=` or end-of-statement. + out = out.replace(/^(\s*(?:const|let|var)\s+\w+)\s*:\s*[^=;\n]+?(\s*=|\s*$)/gm, "$1$2") + // Strip TS non-null assertions (`foo!`): post-fix `!` after an identifier + // or closing bracket, used to assert non-null in TS. Must not strip `!` + // in logical operators (`!=`, `!==`, unary `!foo`). Match `!` immediately + // after a word char or `]`/`)` that is NOT followed by `=`. + out = out.replace(/([\w\])])!(?!=)/g, "$1") return out } @@ -142,7 +166,7 @@ function inlineShared(rawSrc, spawnSyncImpl) { return { sharedCode: sharedSrc + "\n" } } -function loadTool(spawnSyncImpl) { +function loadTool(spawnSyncImpl, fsImpl) { // Provide a CommonJS module sandbox so the tool file's `export default` // becomes accessible via `module.exports.default`. const rawSrc = readFileSync(TS_FILE, "utf-8") @@ -156,24 +180,30 @@ function loadTool(spawnSyncImpl) { src = src.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "") src = src.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "") src = src.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "") + // NOTE: do NOT strip `const { ... } = require("fs")` — the require shim below + // returns the fs module, so the destructuring binds readFileSync/writeFileSync + // in-scope inside the sandbox. Stripping would drop the bindings. // Convert `export default tool({...})` into `module.exports.default = tool({...})` const cjs = src.replace(/^export default /m, "module.exports.default = ") // tool shim with .schema = zodShim (since pipeline-status.ts uses tool.schema.number()) const toolShim = (x) => x toolShim.schema = zodShim + const fs = fsImpl || nodeFs const fn = new Function( "module", "require", "spawnSync", "path", "tool", + "fs", sharedCode + cjs + "\nreturn module.exports.default;", ) return fn({ exports: {} }, (name) => { if (name === "child_process") return { spawnSync: spawnSyncImpl } if (name === "path") return path + if (name === "fs") return fs throw new Error("unexpected require: " + name) - }, spawnSyncImpl, path, toolShim) + }, spawnSyncImpl, path, toolShim, fs) } function buildExecArgs(tool, rawValue) { diff --git a/tests/test_create_readme_tool.py b/tests/test_create_readme_tool.py new file mode 100644 index 0000000..93b3ef9 --- /dev/null +++ b/tests/test_create_readme_tool.py @@ -0,0 +1,381 @@ +"""Tests for .opencode/tools/create-readme.ts — the create-readme custom tool. + +Mirrors tests/test_create_pr_tool.py / test_commit_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/create-readme.ts``. + +Modes used: +- ``load`` — sanity-check that the tool loads and declares mode, repo_name, + tagline_en/ru, features_en/ru, repo, file_path args. +- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: + (a) local create: writes README to file_path (absolute path in tmp dir), + overwriting existing content — regression for issue #148 (create mode + did not overwrite existing local README because file_path was resolved + against the plugin process CWD, not context.worktree). + (b) local create: creates a new file when none exists. + (c) remote create: 2 spawnSync calls (GET sha + PUT), returns API message. + (d) local validate: reads file_path and returns validation result. + (e) validation errors: missing required args, bad repo_name, latin tagline_ru. + +Regression note (issue #148): the local mode previously called +``writeFileSync(file_path, ...)`` / ``readFileSync(file_path, ...)`` with a +relative ``file_path`` (default ``"README.md"``) that resolved against the +plugin process CWD rather than ``context.worktree``. The fix wraps the path +via ``path.resolve(context.worktree, file_path)``. These tests use an +absolute ``file_path`` (in a tmp dir) so the write/read target is unambiguous; +the ``worktree`` passed by the loader is ``REPO_ROOT`` (hardcoded in +_ts_loader.mjs), so a relative path would resolve to REPO_ROOT/README.md and +clobber the repo's own README — tests avoid that by passing absolute paths. +""" + +import json +import os +import subprocess +import tempfile +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" / "create-readme.ts" +TS_FILE_REL = ".opencode/tools/create-readme.ts" + +VALID_CREATE_ARGS = { + "mode": "create", + "repo_name": "test-repo", + "tagline_en": "One-line tagline.", + "tagline_ru": "Короткий теглайн.", + "why_en": "Why this exists.", + "what_en": "What it does.", + "why_ru": "Зачем этот проект.", + "what_ru": "Что делает.", + "quick_start": "pip install -r requirements.txt", + "features_en": [{"emoji": "X", "name": "Feat", "description": "desc"}], + "features_ru": [{"emoji": "X", "name": "Фича", "description": "описание"}], +} + +VALID_README = """# 🚀 test-repo + +![Cover](assets/cover.png) + + +> One tagline. + + +> Короткий теглайн. + + +[English](#-english) | [Русский](#-русский) + +--- + +## 🇺🇸 English + + +### ❓ Why +Why. + +### ✅ What +What. + + + +### Features + +| Feature | Description | +|---------|-------------| +| X Foo | desc | + + +### ⚡ Quick Start +```bash +pip install -r requirements.txt +``` +--- + +## 🇷🇺 Русский + + +### ❓ Зачем +Зачем. + +### ✅ Что +Что. + + + +### Фичи + +| Фича | Описание | +|------|----------| +| X Фича | описание | + + +### ⚡ Быстрый старт +```bash +pip install -r requirements.txt +``` +--- + +## 💬 Support and contacts / Поддержка и контакты + +👉 **[slaid098.dev/support](https://slaid098.dev/support)** +""" + +DELIMITERS = [ + "tagline-en:start", + "tagline-en:end", + "tagline-ru:start", + "tagline-ru:end", + "summary-en:start", + "summary-en:end", + "features-en:start", + "features-en:end", + "summary-ru:start", + "summary-ru:end", + "features-ru:start", + "features-ru:end", +] + + +def _run_loader(*args: str) -> dict: + """Invoke the loader with TS_FILE env set to create-readme.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 _valid_args(file_path: str) -> dict: + """Return a copy of VALID_CREATE_ARGS with file_path set (local mode).""" + return {**VALID_CREATE_ARGS, "file_path": file_path} + + +def test_loader_can_load_tool(): + """Sanity: create-readme.ts loads and declares the expected arguments.""" + if not TS_FILE.exists(): + pytest.skip("create-readme.ts not present") + out = _run_loader("load") + assert "description" in out + args = out["args"] + for key in ( + "mode", + "repo_name", + "tagline_en", + "tagline_ru", + "why_en", + "what_en", + "why_ru", + "what_ru", + "features_en", + "features_ru", + "repo", + "file_path", + ): + assert key in args, f"missing {key} arg: {args}" + + +def test_local_create_overwrites_existing_readme(): + """Regression #148: local create overwrites an existing README at file_path. + + A pre-existing README (non-empty, old content) must be replaced with the + generated content: mtime updates, delimiter tags present, old content gone. + """ + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + readme.write_text("# OLD CONTENT\nshould be overwritten\n", encoding="utf-8") + mtime_before = readme.stat().st_mtime_ns + out = _run_exec(_valid_args(str(readme)), []) + result = out["result"] + assert "created at" in result, f"expected success, got: {result!r}" + mtime_after = readme.stat().st_mtime_ns + assert mtime_after > mtime_before, ( + f"mtime did not advance: before={mtime_before} after={mtime_after}" + ) + content = readme.read_text(encoding="utf-8") + assert "# OLD CONTENT" not in content, "old content survived — overwrite failed" + for d in DELIMITERS: + tag = f"" + assert tag in content, f"missing delimiter {tag} in generated README" + assert "" in content + + +def test_local_create_creates_new_readme(): + """Local create writes a new README when file_path does not exist yet.""" + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + assert not readme.exists() + out = _run_exec(_valid_args(str(readme)), []) + result = out["result"] + assert "created at" in result, f"expected success, got: {result!r}" + assert readme.exists(), "README was not created" + content = readme.read_text(encoding="utf-8") + assert "# 🚀 test-repo" in content + assert "assets/cover.png" in content + + +def test_local_create_custom_subdir_file_path(): + """Local create writes to a nested file_path (subdir must be auto-created?). + + Note: writeFileSync does NOT create parent dirs. This test confirms the + tool writes successfully when the parent directory exists (tmp subdir). + """ + with tempfile.TemporaryDirectory() as tmp: + subdir = Path(tmp) / "docs" + subdir.mkdir() + readme = subdir / "README.md" + out = _run_exec(_valid_args(str(readme)), []) + result = out["result"] + assert "created at" in result, f"expected success, got: {result!r}" + assert readme.exists() + + +def test_remote_create_makes_two_spawnsync_calls(): + """Remote create (args.repo set) makes GET (sha) + PUT via gh api. + + Regression guard: the remote path must not be broken by the local-mode + path.resolve fix. Stub returns sha on GET, {} on PUT. + """ + args = {**VALID_CREATE_ARGS, "repo": "slaid098/test-repo"} + responses = [ + {"status": 0, "stdout": json.dumps({"sha": "abc123"}), "stderr": ""}, + {"status": 0, "stdout": "{}", "stderr": ""}, + ] + out = _run_exec(args, responses) + result = out["result"] + assert "updated in slaid098/test-repo via GitHub API" in result, ( + f"expected remote success, got: {result!r}" + ) + calls = out["calls"] + assert len(calls) == 2, f"expected 2 gh api calls, got {len(calls)}" + assert calls[0]["args"][1] == "repos/slaid098/test-repo/contents/README.md" + assert calls[1]["args"][2] == "PUT" + + +def test_remote_create_passes_worktree_cwd(): + """Remote create spawnSync opts carry cwd=context.worktree (ADR-023).""" + args = {**VALID_CREATE_ARGS, "repo": "slaid098/test-repo"} + responses = [ + {"status": 0, "stdout": json.dumps({"sha": "abc123"}), "stderr": ""}, + {"status": 0, "stdout": "{}", "stderr": ""}, + ] + out = _run_exec(args, responses) + for call in out["calls"]: + opts = call["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd" + assert "cwd" in opts, f"opts missing cwd key: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got {opts['cwd']!r}" + ) + + +def test_local_validate_reads_file_path(): + """Local validate reads the README from file_path and returns ok.""" + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + readme.write_text(VALID_README, encoding="utf-8") + out = _run_exec({"mode": "validate", "file_path": str(readme)}, []) + result = out["result"] + assert "valid" in result.lower(), f"expected valid, got: {result!r}" + + +def test_local_validate_reports_missing_delimiters(): + """Local validate on a malformed README reports missing delimiter issues.""" + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + readme.write_text("# wrong\nno delimiters here\n", encoding="utf-8") + out = _run_exec({"mode": "validate", "file_path": str(readme)}, []) + result = out["result"] + assert "Validation issues" in result, f"expected issues, got: {result!r}" + assert "Missing" in result + + +def test_create_missing_required_repo_name(): + """create without repo_name → error mentioning required.""" + args = {**VALID_CREATE_ARGS} + del args["repo_name"] + with tempfile.TemporaryDirectory() as tmp: + args["file_path"] = str(Path(tmp) / "README.md") + out = _run_exec(args, []) + result = out["result"] + assert "repo_name is required" in result, f"expected required error, got: {result!r}" + + +def test_create_missing_required_tagline_ru(): + """create without tagline_ru → error mentioning required.""" + args = {**VALID_CREATE_ARGS} + del args["tagline_ru"] + with tempfile.TemporaryDirectory() as tmp: + args["file_path"] = str(Path(tmp) / "README.md") + out = _run_exec(args, []) + result = out["result"] + assert "tagline_ru is required" in result, f"expected required error, got: {result!r}" + + +def test_create_bad_repo_name_uppercase(): + """create with uppercase repo_name → kebab-case validation error.""" + with tempfile.TemporaryDirectory() as tmp: + args = { + **VALID_CREATE_ARGS, + "repo_name": "TestRepo", + "file_path": str(Path(tmp) / "README.md"), + } + out = _run_exec(args, []) + result = out["result"] + assert "kebab-case" in result, f"expected kebab-case error, got: {result!r}" + + +def test_create_latin_tagline_ru(): + """create with latin-only tagline_ru → Cyrillic requirement error.""" + with tempfile.TemporaryDirectory() as tmp: + args = { + **VALID_CREATE_ARGS, + "tagline_ru": "latin only", + "file_path": str(Path(tmp) / "README.md"), + } + out = _run_exec(args, []) + result = out["result"] + assert "Cyrillic" in result, f"expected Cyrillic error, got: {result!r}" + + +def test_create_cyrillic_tagline_en(): + """create with Cyrillic tagline_en → English-only requirement error.""" + with tempfile.TemporaryDirectory() as tmp: + args = { + **VALID_CREATE_ARGS, + "tagline_en": "кириллица тут", + "file_path": str(Path(tmp) / "README.md"), + } + out = _run_exec(args, []) + result = out["result"] + assert "no Cyrillic" in result, f"expected no-Cyrillic error, got: {result!r}" + + +def test_create_missing_features_en(): + """create with empty features_en → error mentioning required.""" + with tempfile.TemporaryDirectory() as tmp: + args = {**VALID_CREATE_ARGS, "features_en": [], "file_path": str(Path(tmp) / "README.md")} + out = _run_exec(args, []) + result = out["result"] + assert "features_en is required" in result, f"expected required error, got: {result!r}"