diff --git a/.claude/skills/add-problem/SKILL.md b/.claude/skills/add-problem/SKILL.md index f8caef0..0a23e69 100644 --- a/.claude/skills/add-problem/SKILL.md +++ b/.claude/skills/add-problem/SKILL.md @@ -54,38 +54,15 @@ description: vimro の問題 JSON を作成する。カテゴリ・狙う操作 ## 検証(必須) -スクラッチパッドに一時スクリプトを作り、headless で実行する: - -```lua --- verify.lua(スクラッチパッドに置く。REPO と FILE を差し替える) -local root = "REPO" -- リポジトリ絶対パス -vim.opt.rtp:prepend(root) -local p = vim.json.decode(table.concat(vim.fn.readfile(root .. "/problems/FILE"), "\n")) -for i, s in ipairs(p.solutions) do - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, p.start) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(s.keys, true, false, true), "x", false) - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) - local got = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert(require("vimro.engine").is_cleared(got, p.goal), - ("solution %d (%s) failed: %s"):format(i, s.keys, vim.inspect(got))) -end -print("OK") -``` +リポジトリルートで実行する。solutions の再生とスキーマを機械確認する(CI でも同じものが走るが、壊れた PR を出さないためにローカルで先に通す): ```sh -nvim --headless -u NONE -l verify.lua +nvim --headless -u NONE -l scripts/verify_problems.lua ``` -これで **全 solutions の keys が start を goal に本当に変形するか**を機械確認する。失敗したら `start` / `goal` / `keys` を見直す。 - -あわせて次も目視確認する: +失敗すると該当 `id` と実際に生成されたバッファ内容が出るので、`start` / `goal` / `keys` を見直す。 -- `notes` の要素数が `solutions` と一致しているか(ja / en 両方) -- `id` の重複がないか(`grep -r '"id"' problems//`) -- `description` と `goal` が矛盾していないか +これに加えて **`description` と `goal` が矛盾していないか**だけは目視で確認する(機械検証できない唯一の項目)。 ## PR の作成 @@ -103,12 +80,11 @@ gh pr create --base main --label problems \ ## Summary - 追加した問題と狙う操作を1〜2行で -## Verification -- headless Neovim で全 solutions を再生し goal に到達することを確認 - 🤖 Generated with [Claude Code](https://claude.com/claude-code) EOF )" ``` `problems` ラベルはリポジトリに作成済みなので `--label problems` をそのまま使う。付与に失敗した場合のみ `gh pr edit <番号> --add-label problems` で追う。作成後は PR の URL をユーザーに伝える。 + +**ラベルはリリースのバージョンを決める**: main にマージされると `.github/workflows/release.yml` が自動でタグを打ってリリースする。`problems` ラベル(およびラベル無し)は patch、`enhancement` は minor、`breaking` は major。問題追加は patch のままでよいので、通常は `problems` だけ付ければよい。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3cd1c16 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + verify: + name: Verify problems + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + - run: nvim --headless -u NONE -l scripts/verify_problems.lua + + load: + name: Load plugin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + - run: nvim --headless -u NONE -l scripts/load_check.lua diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0a253a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + - name: Verify before releasing + run: | + nvim --headless -u NONE -l scripts/verify_problems.lua + nvim --headless -u NONE -l scripts/load_check.lua + + - name: Skip if this commit is already tagged + id: tagged + run: | + if git tag --points-at HEAD | grep -q '^v'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Decide the next version + id: version + if: steps.tagged.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + labels=$(gh pr list --state merged --search "$GITHUB_SHA" \ + --json labels --jq '.[0].labels[].name' 2>/dev/null || true) + echo "labels: ${labels:-}" + + bump=patch + if grep -qx 'breaking' <<<"$labels"; then + bump=major + elif grep -qx 'enhancement' <<<"$labels"; then + bump=minor + fi + + prev=$(git tag --list 'v*' --sort=-v:refname | head -1) + prev=${prev:-v0.0.0} + IFS=. read -r major minor patch <<<"${prev#v}" + + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + + next="v${major}.${minor}.${patch}" + echo "$prev -> $next ($bump)" + { + echo "prev=$prev" + echo "next=$next" + } >> "$GITHUB_OUTPUT" + + - name: Tag and release + if: steps.tagged.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + PREV: ${{ steps.version.outputs.prev }} + NEXT: ${{ steps.version.outputs.next }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$NEXT" -m "$NEXT" + git push origin "$NEXT" + + gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$NEXT" -f previous_tag_name="$PREV" \ + --jq .body > notes.md + ./scripts/problem_delta.sh "$PREV" >> notes.md + + gh release create "$NEXT" --title "$NEXT" --notes-file notes.md diff --git a/CLAUDE.md b/CLAUDE.md index e47f4ef..95d7c36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,29 +16,38 @@ Run the plugin from the repo without installing it: nvim --cmd "set rtp+=$PWD" +Vimro ``` -Verify a problem's solutions actually transform `start` into `goal` (headless, no config): +Verify every problem — schema plus a real headless replay of each solution against `goal`: ```sh -nvim --headless -u NONE -l verify.lua # see .claude/skills/add-problem/SKILL.md for the script +nvim --headless -u NONE -l scripts/verify_problems.lua ``` -That headless-replay pattern is the only automated check in the repo — use it whenever problem JSON changes. +Check that every module still loads: + +```sh +nvim --headless -u NONE -l scripts/load_check.lua +``` + +Both run in CI (`.github/workflows/ci.yml`) on every PR and push to main. Run `verify_problems.lua` locally whenever problem JSON changes — a failure prints the offending `id` and the buffer it actually produced. + +## Release automation + +Merging to `main` releases automatically (`.github/workflows/release.yml`): it re-runs both checks, reads the merged PR's labels to pick the bump (`breaking` → major, `enhancement` → minor, anything else including `problems` → patch), tags `vX.Y.Z` off the latest `v*` tag, and publishes a GitHub release. Notes are GitHub's generated ones plus a problem-count delta from `scripts/problem_delta.sh`. A commit that already carries a `v*` tag is skipped, so re-runs are safe. Tags without the `v` prefix (`1.0.0`, `1.1.0`) are legacy and ignored by the version lookup. ## Architecture -Five modules, each with one job. `ui.lua` is the only one that knows about the others. +Four modules, each with one job. `ui.lua` is the only one that knows about the others. - **`engine.lua`** — no UI, no state beyond the progress file. Loads/sorts problems (difficulty, then id), decides clearing, persists progress. `root()` derives the plugin directory from `debug.getinfo`, so `problems/` is always found relative to the installed plugin, not the cwd. - **`ui.lua`** — session state (`S`), the two-window tab layout, keymaps, rendering, startup flow. Everything user-visible lives here. - **`config.lua`** — `M.defaults` is the single source of truth for options; `setup()` deep-extends into `M.options`. `ui.lua` reads `config.options` live, so runtime mutation (e.g. the language picker setting `config.options.lang`) takes effect immediately. - **`i18n/`** — `t(key, ...)` for UI strings, `resolve_problem()` for per-problem text. Both fall back `lang` → `fallback_lang`; `t()` finally falls back to the key itself, so a missing string degrades rather than errors. -- **`keys.lua`** — keystroke counter via a single `vim.on_key` handler, counting only while the practice buffer is current. ### Things that will bite you - **Clear matching is deliberately loose in one axis only**: `engine.normalize` strips trailing whitespace per line and drops trailing blank lines. Line contents and line count are otherwise strict. Problems whose answer depends on trailing whitespace cannot be expressed. - **Keymaps in the practice buffer must stay behind `buffer_prefix`.** Binding plain `n` / `r` there would shadow the exact Vim motions being trained. The problem pane is where bare keys are safe. -- **Categories are hardcoded in `ui.lua`** (`local categories = { "plain" }`) even though `engine.load_problems` accepts any directory name. Adding a new `problems//` requires editing that list too. +- **Categories are auto-detected** by `engine.list_categories()` from the subdirectories of `problems/`, so a new category needs no code change. - **Every problem starts at `[1, 1]`.** There is no per-problem start position: getting to the spot you edit is part of the drill, and a mid-line start reads as a leftover cursor from the previous problem. Solutions must therefore include whatever motion they need. - **`quit()` has two exit paths**: started from an empty Neovim (`is_fresh_nvim`) it runs `qa` and exits the editor; otherwise it tears down the tab/split and wipes buffers. Both must leave `S` clean, since `M.start()` guards re-entry on `S.active`. - Both buffers are `nofile` + `bufhidden=wipe`, and a `BufWipeout` autocmd on the practice buffer ends the session — wiping it from anywhere is a supported way to quit. diff --git a/README.ja.md b/README.ja.md index 1c3de32..bfe9db1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -118,7 +118,7 @@ require("vimro").setup({ - 言語非依存フィールドを埋める: `id` / `category` / `difficulty` / `start` / `goal` / `solutions`(`keys` は `` などの vim 表記で、常に 1 行目 1 文字目から始まる。最短解に `"optimal": true`)/ `tags` - `i18n.ja` と `i18n.en` の両方を埋める(`title` / `description` / `hints` / `notes`)。`notes[i]` は `solutions[i]` の説明 -- 各 `solutions[].keys` が本当に `start` を `goal` に変形することを Neovim で確認する +- `nvim --headless -u NONE -l scripts/verify_problems.lua` で検証する。全 `solutions[].keys` を実際に再生し、スキーマも合わせて確認する(同じスクリプトが CI でも走る) - 1 問につき狙う操作は 1 つに絞り、`description` と `goal` を必ず一致させる 完全な例は `problems/plain/001-delete-word.json` を参照してください。 diff --git a/README.md b/README.md index d1f8984..36ae3a9 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Problems live in `problems//NNN-.json`, one file per problem: - Fill the language-independent fields: `id`, `category`, `difficulty`, `start`, `goal`, `solutions` (`keys` in Vim notation like ``, starting from the first character of the buffer, mark the shortest with `"optimal": true`), `tags`. - Fill both `i18n.ja` and `i18n.en` (`title`, `description`, `hints`, `notes`). `notes[i]` describes `solutions[i]`. -- Verify in Neovim that each `solutions[].keys` really transforms `start` into `goal`. +- Verify with `nvim --headless -u NONE -l scripts/verify_problems.lua`, which replays every `solutions[].keys` and checks the schema. The same script runs in CI on every pull request. - Aim for one target operation per problem, and keep `description` consistent with `goal`. See `problems/plain/001-delete-word.json` for a complete example. diff --git a/scripts/load_check.lua b/scripts/load_check.lua new file mode 100644 index 0000000..b2b9b54 --- /dev/null +++ b/scripts/load_check.lua @@ -0,0 +1,31 @@ +-- Require every module under lua/vimro/ so syntax and load-time errors surface. +-- +-- nvim --headless -u NONE -l scripts/load_check.lua + +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") +vim.opt.rtp:prepend(root) + +local files = vim.fn.glob(root .. "/lua/vimro/**/*.lua", false, true) +table.sort(files) + +local failed = false +for _, file in ipairs(files) do + local mod = file:sub(#root + #"/lua/" + 1):gsub("%.lua$", ""):gsub("/init$", ""):gsub("/", ".") + local ok, err = pcall(require, mod) + if not ok then + failed = true + io.stderr:write(("%s: %s\n"):format(mod, err)) + end +end + +-- plugin/ scripts are sourced by Neovim, not required +local ok, err = pcall(vim.cmd, "source " .. root .. "/plugin/vimro.lua") +if not ok then + failed = true + io.stderr:write("plugin/vimro.lua: " .. tostring(err) .. "\n") +end + +if failed then + vim.cmd("cq") +end +io.stdout:write(("OK - %d modules loaded\n"):format(#files)) diff --git a/scripts/problem_delta.sh b/scripts/problem_delta.sh new file mode 100755 index 0000000..2758ed7 --- /dev/null +++ b/scripts/problem_delta.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Summarize problems added between a previous ref and HEAD, for release notes. +# +# scripts/problem_delta.sh v1.2.1 +# +# Prints nothing when no problems were added. +set -euo pipefail + +prev="${1:-}" +[ -n "$prev" ] || exit 0 + +added=$(git diff --diff-filter=A --name-only "$prev" HEAD -- 'problems/*/*.json' || true) +[ -n "$added" ] || exit 0 + +count=$(printf '%s\n' "$added" | wc -l | tr -d ' ') +by_category=$(printf '%s\n' "$added" | cut -d/ -f2 | sort | uniq -c | + awk '{ printf "%s%s: %s", sep, $2, $1; sep = ", " }') +total=$(git ls-files 'problems/*/*.json' | wc -l | tr -d ' ') + +printf '\n## Problems\n\n+%s problems (%s) — %s total\n' "$count" "$by_category" "$total" diff --git a/scripts/verify_problems.lua b/scripts/verify_problems.lua new file mode 100644 index 0000000..efdb55e --- /dev/null +++ b/scripts/verify_problems.lua @@ -0,0 +1,135 @@ +-- Verify every problem in problems/: schema, and that each solution really +-- transforms `start` into `goal` when replayed in a scratch buffer. +-- +-- nvim --headless -u NONE -l scripts/verify_problems.lua + +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") +vim.opt.rtp:prepend(root) + +local engine = require("vimro.engine") + +local errors = {} +local seen_ids = {} +local counts = {} +local total = 0 + +local function fail(where, msg) + table.insert(errors, where .. ": " .. msg) +end + +local function check_schema(file, p) + local name = vim.fn.fnamemodify(file, ":t") + local category = vim.fn.fnamemodify(file, ":h:t") + + if type(p.id) ~= "string" or not p.id:match("^" .. category .. "%-%d%d%d$") then + fail(name, ("id %s does not match <%s>-NNN"):format(vim.inspect(p.id), category)) + return + end + if seen_ids[p.id] then + fail(name, ("duplicate id %s (also in %s)"):format(p.id, seen_ids[p.id])) + end + seen_ids[p.id] = name + + if type(p.difficulty) ~= "number" or p.difficulty % 1 ~= 0 or p.difficulty < 1 or p.difficulty > 3 then + fail(p.id, "difficulty must be an integer 1-3, got " .. vim.inspect(p.difficulty)) + end + for _, field in ipairs({ "start", "goal", "solutions" }) do + if type(p[field]) ~= "table" or vim.tbl_isempty(p[field]) then + fail(p.id, field .. " must be a non-empty array") + end + end + if type(p.solutions) ~= "table" then + return + end + + local optimal = 0 + for i, s in ipairs(p.solutions) do + if type(s.keys) ~= "string" or s.keys == "" then + fail(p.id, ("solutions[%d].keys must be a non-empty string"):format(i)) + end + if s.optimal == true then + optimal = optimal + 1 + end + end + if optimal ~= 1 then + fail(p.id, ("exactly one solution must be optimal, found %d"):format(optimal)) + end + + for _, lang in ipairs({ "ja", "en" }) do + local tr = p.i18n and p.i18n[lang] + if type(tr) ~= "table" then + fail(p.id, "missing i18n." .. lang) + else + for _, field in ipairs({ "title", "description" }) do + if type(tr[field]) ~= "string" or tr[field] == "" then + fail(p.id, ("i18n.%s.%s must be a non-empty string"):format(lang, field)) + end + end + if type(tr.hints) ~= "table" or vim.tbl_isempty(tr.hints) then + fail(p.id, ("i18n.%s.hints must be a non-empty array"):format(lang)) + end + if type(tr.notes) ~= "table" then + fail(p.id, ("i18n.%s.notes must be an array"):format(lang)) + elseif #tr.notes ~= #p.solutions then + fail(p.id, ("i18n.%s.notes has %d entries but there are %d solutions"):format(lang, #tr.notes, #p.solutions)) + end + end + end +end + +local function check_solutions(p) + if type(p.start) ~= "table" or type(p.goal) ~= "table" or type(p.solutions) ~= "table" then + return + end + for i, s in ipairs(p.solutions) do + if type(s.keys) == "string" then + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, p.start) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + local ok, err = pcall(function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(s.keys, true, false, true), "x", false) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + end) + if not ok then + fail(p.id, ("solution %d (%s) errored: %s"):format(i, s.keys, err)) + else + local got = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + if not engine.is_cleared(got, p.goal) then + fail(p.id, ("solution %d (%s) produced %s, want %s"):format(i, s.keys, vim.inspect(got), vim.inspect(p.goal))) + end + end + vim.api.nvim_buf_delete(buf, { force = true }) + end + end +end + +for _, category in ipairs(engine.list_categories()) do + local files = vim.fn.glob(root .. "/problems/" .. category .. "/*.json", false, true) + table.sort(files) + counts[category] = #files + for _, file in ipairs(files) do + total = total + 1 + local ok, p = pcall(vim.json.decode, table.concat(vim.fn.readfile(file), "\n")) + if not ok or type(p) ~= "table" then + fail(vim.fn.fnamemodify(file, ":t"), "invalid JSON: " .. tostring(p)) + else + check_schema(file, p) + check_solutions(p) + end + end +end + +if #errors > 0 then + io.stderr:write(("%d problem(s) failed verification:\n"):format(#errors)) + for _, e in ipairs(errors) do + io.stderr:write(" " .. e .. "\n") + end + vim.cmd("cq") +end + +local parts = {} +for _, category in ipairs(engine.list_categories()) do + table.insert(parts, ("%s: %d"):format(category, counts[category])) +end +io.stdout:write(("OK - %d problems verified (%s)\n"):format(total, table.concat(parts, ", ")))