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:
parent
1bc8b8ed05
commit
4258621def
7 changed files with 586 additions and 10 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
docs/decisions/073-pr-175-overwrite-existing-local-readme.md
Normal file
52
docs/decisions/073-pr-175-overwrite-existing-local-readme.md
Normal 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` во временных директориях.
|
||||
108
docs/handoff/pr-175-overwrite-existing-local-readme.md
Normal file
108
docs/handoff/pr-175-overwrite-existing-local-readme.md
Normal 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 (зафиксировано в
|
||||
комментарии теста).
|
||||
|
|
@ -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 рендерит `` после 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 рендерит `` после 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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
381
tests/test_create_readme_tool.py
Normal file
381
tests/test_create_readme_tool.py
Normal 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
|
||||
|
||||

|
||||
|
||||
<!-- 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}"
|
||||
Loading…
Add table
Reference in a new issue