fix(repo-readme): create mode does not overwrite existing local README (#175)

* fix(create-readme): resolve file_path against context.worktree

* test(loader): support fs imports and extended TS stripping

* test(create-readme): add regression tests for local overwrite

* docs(repo-readme): note file_path resolves against worktree

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-31 21:10:29 +03:00 committed by GitHub
parent 1bc8b8ed05
commit 4258621def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 586 additions and 10 deletions

View file

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

View file

@ -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<string, string | undefined> = {
@ -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)

View file

@ -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` во временных директориях.

View file

@ -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 (зафиксировано в
комментарии теста).

View file

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

View file

@ -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 <Name> = ...` type alias declarations (single-line, optional `;`).
out = out.replace(/^type\s+\w+\s*=\s*.+$\s*$/m, "")
// Strip `type <Name> = ...` 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 `: <Type>`
// 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) {

View file

@ -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)
<!-- tagline-en:start -->
> One tagline.
<!-- tagline-en:end -->
<!-- tagline-ru:start -->
> Короткий теглайн.
<!-- tagline-ru:end -->
[English](#-english) | [Русский](#-русский)
---
## 🇺🇸 English
<!-- summary-en:start -->
### ❓ Why
Why.
### ✅ What
What.
<!-- summary-en:end -->
<!-- features-en:start -->
### Features
| Feature | Description |
|---------|-------------|
| X Foo | desc |
<!-- features-en:end -->
### ⚡ Quick Start
```bash
pip install -r requirements.txt
```
---
## 🇷🇺 Русский
<!-- summary-ru:start -->
### ❓ Зачем
Зачем.
### ✅ Что
Что.
<!-- summary-ru:end -->
<!-- features-ru:start -->
### Фичи
| Фича | Описание |
|------|----------|
| X Фича | описание |
<!-- features-ru:end -->
### ⚡ Быстрый старт
```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"<!-- {d} -->"
assert tag in content, f"missing delimiter {tag} in generated README"
assert "<!-- summary-en:start -->" 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}"