From 5a8a849d94aaba84e9258a43ad5d56701708ed53 Mon Sep 17 00:00:00 2001 From: 844196 <844196@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:33:25 +0900 Subject: [PATCH 1/2] feat!: Validate every configuration field against one schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the outermost shape of config.yaml and bindings.yaml was checked, so a field could be wrong in a way nothing noticed. Some of those reached the drawing code and crashed with a stack trace, leaving the menu on screen; others were quietly ignored, and a bad outputDelimiter put stray bytes into BUFFER. src/schema.ts is now the single source of truth. The types, the defaults, the runtime checks and the distributable JSON Schemas all derive from it, so a field can no longer be enforced in one place and forgotten in another. A violation stops wk with exit 7 and one line naming the path that failed (`bindings.yaml: [0].bindings[0].key: expected a key name or a digit 0-9`). What the schema does not describe — an unknown top-level config field — is still left alone. An unquoted digit is now accepted as a key: YAML reads it as a number, which wk reads back as the digit that was typed. The schemas are generated rather than committed, and ride in the release archive, so they cannot drift from what wk enforces. BREAKING CHANGE: releases ship a tarball holding the binary, the schemas and the license, rather than a bare binary. BREAKING CHANGE: a field that violates the schema stops wk. Values that used to be ignored — a non-boolean eval, a non-scalar extra field on a command, a colour outside the ANSI range, a timeout that is not a whole number of milliseconds or that runs past five minutes — are now rejected. Co-Authored-By: Claude Opus 5 --- .github/workflows/check.yaml | 5 + .github/workflows/release.yaml | 16 +- README.md | 35 +++- deno.jsonc | 1 + deno.lock | 5 + e2e/tests/01_protocol.bats | 18 -- e2e/tests/04_config.bats | 19 +- e2e/tests/09_validation.bats | 322 +++++++++++++++++++++++++++++++++ mise.toml | 23 ++- schemas/bindings.json | 95 ---------- schemas/config.json | 119 ------------ scripts/generate-schemas.ts | 39 ++++ src/main.ts | 2 +- src/run.ts | 46 ++--- src/schema.ts | 245 +++++++++++++++++++++++++ src/types/Binding.ts | 21 --- src/types/Context.ts | 125 ------------- src/ui.ts | 3 +- 18 files changed, 709 insertions(+), 430 deletions(-) create mode 100644 e2e/tests/09_validation.bats delete mode 100644 schemas/bindings.json delete mode 100644 schemas/config.json create mode 100644 scripts/generate-schemas.ts create mode 100644 src/schema.ts delete mode 100644 src/types/Binding.ts delete mode 100644 src/types/Context.ts diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e77b825..6243c74 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -52,6 +52,11 @@ jobs: - name: Run build run: mise run build + # Also exercises schema generation and packaging, so that a broken + # release tarball is caught before the tag is pushed. + - name: Run dist + run: mise run dist x86_64-unknown-linux-gnu + e2e: name: E2E runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5c4ba32..d82f808 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -36,14 +36,14 @@ jobs: - name: Setup project uses: ./.github/actions/setup-project - - name: Run build - run: VERSION=${{ github.ref_name }} mise run build:internal ${{ matrix.target }} + - name: Run dist + run: VERSION=${{ github.ref_name }} mise run dist ${{ matrix.target }} - - name: Upload binary + - name: Upload tarball uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: binary-${{ matrix.target }} - path: dist/wk-${{ matrix.target }} + name: tarball-${{ matrix.target }} + path: dist/wk-${{ matrix.target }}.tar.gz if-no-files-found: error retention-days: 1 @@ -84,10 +84,10 @@ jobs: permissions: contents: write steps: - - name: Download binaries + - name: Download tarballs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: binary-* + pattern: tarball-* path: dist merge-multiple: true @@ -97,6 +97,6 @@ jobs: name: release-notes - name: Create draft release - run: gh release create ${{ github.ref_name }} dist/wk-* --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag + run: gh release create ${{ github.ref_name }} dist/wk-*.tar.gz --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index d8c78ef..1cf9b75 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,23 @@ ## :package: Installation -1. Download the latest release and put into your `$PATH`: +1. Download the archive for your platform from the latest release and extract it: -2. Activate in `$ZDOTDIR/.zshrc`: + ```shell + tar xzf wk-x86_64-unknown-linux-gnu.tar.gz + ``` + + It holds the `wk` binary, the JSON Schemas for the configuration files, and the license. + +2. Put the binary into your `$PATH`: + + ```shell + install -m 755 wk-x86_64-unknown-linux-gnu/wk ~/.local/bin/ + ``` + +3. Activate in `$ZDOTDIR/.zshrc`: ```shell # Bind space as the leader key and comma as the major-leader key. @@ -19,7 +31,7 @@ eval "$(wk init --leader ' ' --major-leader ',' --major-prefix 'm')" ``` -3. Restart zsh. +4. Restart zsh. > [!TIP] > If you want to register only the widgets, change it as follows: @@ -59,7 +71,11 @@ colors: bindingDescription: 8 ``` -See [schemas/config.json](./schemas/config.json) for more details. +Point your editor at the schema in the extracted archive to get completion and validation: + +```yaml +# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/config.json +``` ### Global bindings @@ -100,7 +116,16 @@ See [schemas/config.json](./schemas/config.json) for more details. accept: true ``` -See [schemas/bindings.json](./schemas/bindings.json) for more details. +Point your editor at the schema in the extracted archive to get completion and validation: + +```yaml +# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/bindings.json +``` + +A `key` is the key to press. YAML reads an unquoted digit as a number, and wk reads it back as that +digit, so `key: 1` and `key: '1'` both bind the `1` key. Most punctuation needs no quoting either — +`key: .` above is one. YAML's indicator characters do, though: unquoted they are read as null or +rejected outright, so write `key: '~'` and `key: ':'`. ### Local bindings diff --git a/deno.jsonc b/deno.jsonc index 8fdd628..4e5a0f3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -24,6 +24,7 @@ "@std/fmt": "jsr:@std/fmt@1.0.3", "@std/path": "jsr:@std/path@1.0.8", "@std/yaml": "jsr:@std/yaml@1.0.5", + "@zod/mini": "jsr:@zod/zod@4.5.4/mini", "xdg-basedir": "npm:xdg-basedir@5.1.0" }, "unstable": ["raw-imports"] diff --git a/deno.lock b/deno.lock index 2573d23..2bd741f 100644 --- a/deno.lock +++ b/deno.lock @@ -16,6 +16,7 @@ "jsr:@std/path@1.0.8": "1.0.8", "jsr:@std/text@~1.0.7": "1.0.15", "jsr:@std/yaml@1.0.5": "1.0.5", + "jsr:@zod/zod@4.5.4": "4.5.4", "npm:@types/node@*": "22.13.8", "npm:xdg-basedir@5.1.0": "5.1.0" }, @@ -84,6 +85,9 @@ }, "@std/yaml@1.0.5": { "integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742" + }, + "@zod/zod@4.5.4": { + "integrity": "bf0da80e42232d5f9f485d37b5c980e0d88f07fe04b9b1f2bd9012c008404df4" } }, "npm": { @@ -111,6 +115,7 @@ "jsr:@std/fmt@1.0.3", "jsr:@std/path@1.0.8", "jsr:@std/yaml@1.0.5", + "jsr:@zod/zod@4.5.4", "npm:xdg-basedir@5.1.0" ] } diff --git a/e2e/tests/01_protocol.bats b/e2e/tests/01_protocol.bats index f53aa3b..88985f9 100644 --- a/e2e/tests/01_protocol.bats +++ b/e2e/tests/01_protocol.bats @@ -113,24 +113,6 @@ YAML assert_equal "$output" $'\t\tls -la\tnote:hello world' } -@test "non-scalar fields are dropped" { - write_bindings <<'YAML' -- key: l - type: command - buffer: ls -la - num: 1 - arr: [a, b] - obj: { a: 1 } - str: kept -YAML - - wk_run --inputs 'l' - - assert_equal "$status" 0 - # run.ts only forwards string and boolean values. - assert_equal "$output" $'\t\tls -la\tstr:kept' -} - @test "an empty buffer still produces the two leading delimiters" { write_bindings <<'YAML' - key: l diff --git a/e2e/tests/04_config.bats b/e2e/tests/04_config.bats index 06b6d54..af03958 100644 --- a/e2e/tests/04_config.bats +++ b/e2e/tests/04_config.bats @@ -86,20 +86,25 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: expected a list of bindings" } -@test "a bindings entry without a string key stops wk" { +@test "a bindings entry without a key stops wk, naming the entry" { write_bindings <<'YAML' +- desc: fine + key: a + type: command + buffer: ls -la - desc: no key here type: command buffer: ls -la YAML - wk_run --inputs 'l' + wk_run --inputs 'a' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [1].key: expected a key name or a digit 0-9" } @test "a config file holding a scalar stops wk" { @@ -110,7 +115,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: expected a mapping" } @test "config is read before bindings, so the first broken file wins" { @@ -124,7 +129,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: expected a mapping" } @test "a directory in place of a config file stops wk" { @@ -148,7 +153,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" '~/.config/wk/bindings.yaml: invalid format' + assert_equal "$stderr" '~/.config/wk/bindings.yaml: expected a list of bindings' } @test "global and local bindings are concatenated with global first" { diff --git a/e2e/tests/09_validation.bats b/e2e/tests/09_validation.bats new file mode 100644 index 0000000..faccbb1 --- /dev/null +++ b/e2e/tests/09_validation.bats @@ -0,0 +1,322 @@ +#!/usr/bin/env bats +# +# What a field may hold. Every rejection is exit 7 with one line naming the path +# that failed, so that a bad field never reaches the drawing code. + +setup() { + load '../helpers/common' + setup_wk_env +} + +# A binding that is fine on its own, so that a config-only test still has +# something to press. +write_valid_bindings() { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la +YAML +} + +# --- config --------------------------------------------------------------- + +@test "a non-string outputDelimiter stops wk" { + write_valid_bindings + write_config <<'YAML' +outputDelimiter: 42 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: outputDelimiter: expected a single character" +} + +@test "a multi-character outputDelimiter stops wk" { + write_valid_bindings + write_config <<'YAML' +outputDelimiter: '::' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: outputDelimiter: expected a single character" +} + +@test "a non-numeric timeout stops wk rather than silently never firing" { + write_valid_bindings + write_config <<'YAML' +timeout: 5s +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: timeout: expected a whole number of milliseconds" +} + +@test "a malformed colour stops wk instead of crashing the drawing code" { + write_valid_bindings + write_config <<'YAML' +colors: + prompt: {} +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: colors.prompt: expected a color" +} + +@test "a colour outside the ANSI range stops wk" { + write_valid_bindings + write_config <<'YAML' +colors: + prompt: 999 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: colors.prompt: expected a color" +} + +@test "an unknown config field is left alone" { + write_valid_bindings + # The schema puts no ceiling on the top level, so a field wk does not know is + # not wk's business. + write_config <<'YAML' +unknownField: whatever +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tls -la' +} + +# --- bindings ------------------------------------------------------------- + +@test "a container written with nothing under it falls back to the defaults" { + write_valid_bindings + # Commenting out every entry under `colors:` leaves YAML null, which means the + # same as never having written the key. + write_config <<'YAML' +timeout: 500 +colors: + # bindingKey: 4 +symbols: + keys: +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tls -la' +} + +@test "an unquoted digit is accepted as a key" { + # YAML reads it as a number; wk reads it back as the digit that was typed. + write_bindings <<'YAML' +- key: 1 + type: command + buffer: first +YAML + + wk_run --inputs '1' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tfirst' +} + +@test "a binding without a type stops wk" { + write_bindings <<'YAML' +- key: l + buffer: ls -la +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].type: expected type \"command\" or \"bindings\"" +} + +@test "a non-string buffer stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: 42 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].buffer: expected a string" +} + +@test "an empty binding delimiter stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + delimiter: '' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].delimiter: expected a single character" +} + +@test "a non-string extra field on a command stops wk" { + # The extra fields become the `key:value` pairs of the output protocol, which + # only carries strings and booleans. + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + note: 1 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].note: expected a string or a boolean" +} + +@test "a list-valued extra field on a command stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + arr: [a, b] +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].arr: expected a string or a boolean" +} + +@test "a non-boolean eval stops wk" { + # `eval` and `accept` are named in the schema rather than left to the catchall, + # because the widget only acts on the literal `true`. + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + eval: 'yes' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].eval: expected a boolean" +} + +@test "a group without a description stops wk" { + write_bindings <<'YAML' +- key: g + type: bindings + bindings: + - key: p + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].desc: expected a string" +} + +@test "a group whose bindings are not a list stops wk" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: oops +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings: expected a list of bindings" +} + +@test "an unknown field on a group stops wk, naming the field" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + oops: 1 + bindings: + - key: p + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0]: unknown field \"oops\"" +} + +@test "a nested binding is checked too, and the path leads to it" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: + - key: p + desc: Push/Pull + type: bindings + bindings: + - key: {} + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings[0].bindings[0].key: expected a key name or a digit 0-9" +} + +@test "an empty group stops wk" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: [] +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings: expected at least one binding" +} + +@test "a local bindings file is validated the same way" { + write_local_bindings <<'YAML' +- key: x + type: command + buffer: 42 +YAML + + wk_run --inputs 'x' + + assert_equal "$status" 7 + assert_equal "$stderr" "${PWD}/wk.bindings.yaml: [0].buffer: expected a string" +} diff --git a/mise.toml b/mise.toml index f6f1071..651ca36 100644 --- a/mise.toml +++ b/mise.toml @@ -17,7 +17,7 @@ run = 'deno lint' [tasks.'check:type'] depends = 'generate:version' -run = 'deno check src/wk.ts' +run = 'deno check src/wk.ts scripts/generate-schemas.ts' [tasks.'generate:version'] run = 'echo "${VERSION:-unknown}" > VERSION' @@ -35,6 +35,27 @@ depends = 'bundle' run = 'deno compile --allow-all --target {{arg(name="target")}} --output dist/wk-{{arg(name="target")}} dist/wk.bundle.js' hide = true +[tasks.'generate:schemas'] +description = 'Generate the distributable JSON Schemas from src/schema.ts' +run = 'deno run --allow-write=dist scripts/generate-schemas.ts' + +[tasks.dist] +description = 'Package a target into dist/wk-.tar.gz with its schemas and LICENSE' +depends = 'generate:schemas' +run = ''' +set -eu +target='{{arg(name="target")}}' +mise run build:internal "${target}" +stage="dist/pkg/wk-${target}" +rm -rf "${stage}" +mkdir -p "${stage}" +cp "dist/wk-${target}" "${stage}/wk" +cp LICENSE "${stage}/" +cp -R dist/schemas "${stage}/schemas" +tar -czf "dist/wk-${target}.tar.gz" -C dist/pkg "wk-${target}" +rm -rf dist/pkg +''' + [tasks.bundle] depends = 'generate:version' run = 'deno bundle --minify --output dist/wk.bundle.js src/wk.ts' diff --git a/schemas/bindings.json b/schemas/bindings.json deleted file mode 100644 index 313c457..0000000 --- a/schemas/bindings.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Bindings" - }, - { - "$ref": "#/definitions/Command" - } - ] - }, - "minItems": 1, - "definitions": { - "Command": { - "type": "object", - "required": ["key", "type", "buffer"], - "properties": { - "key": { - "type": "string", - "minLength": 1 - }, - "desc": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["command"] - }, - "buffer": { - "type": "string" - }, - "delimiter": { - "type": "string", - "minLength": 1, - "maxLength": 1 - }, - "eval": { - "type": "boolean" - }, - "accept": { - "type": "boolean" - } - }, - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ] - } - }, - "Bindings": { - "type": "object", - "required": ["key", "desc", "type", "bindings"], - "additionalProperties": false, - "properties": { - "key": { - "type": "string", - "minLength": 1 - }, - "desc": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["bindings"] - }, - "bindings": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Bindings" - }, - { - "$ref": "#/definitions/Command" - } - ] - }, - "minItems": 1 - } - } - } - } -} diff --git a/schemas/config.json b/schemas/config.json deleted file mode 100644 index 929b860..0000000 --- a/schemas/config.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "type": "object", - "properties": { - "outputDelimiter": { - "type": "string", - "minLength": 1, - "maxLength": 1 - }, - "timeout": { - "type": "integer", - "minimum": 0 - }, - "symbols": { - "type": "object", - "properties": { - "prompt": { - "type": "string" - }, - "breadcrumb": { - "type": "string" - }, - "separator": { - "type": "string" - }, - "group": { - "type": "string" - }, - "keys": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "colors": { - "type": "object", - "properties": { - "prompt": { - "$ref": "#/definitions/Color" - }, - "breadcrumb": { - "$ref": "#/definitions/Color" - }, - "separator": { - "$ref": "#/definitions/Color" - }, - "group": { - "$ref": "#/definitions/Color" - }, - "inputKeys": { - "$ref": "#/definitions/Color" - }, - "lastInputKey": { - "$ref": "#/definitions/Color" - }, - "bindingKey": { - "$ref": "#/definitions/Color" - }, - "bindingIcon": { - "$ref": "#/definitions/Color" - }, - "bindingDescription": { - "$ref": "#/definitions/Color" - } - } - } - }, - "definitions": { - "Color": { - "anyOf": [ - { - "$ref": "#/definitions/ANSIColor" - }, - { - "type": "object", - "properties": { - "color": { - "$ref": "#/definitions/ANSIColor" - }, - "attrs": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "bold", - "dim", - "italic", - "underline", - "inverse", - "hidden", - "strikethrough" - ] - }, - "minItems": 1 - } - }, - "required": [ - "color", - "attrs" - ] - } - ] - }, - "ANSIColor": { - "anyOf": [ - { - "type": "integer", - "minimum": -1, - "maximum": 255 - }, - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - } - } -} diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts new file mode 100644 index 0000000..8f2ab7e --- /dev/null +++ b/scripts/generate-schemas.ts @@ -0,0 +1,39 @@ +import * as z from '@zod/mini' +import { BindingsSchema, ContextSchema, nullableInputs } from '../src/schema.ts' + +// These land in the release tarball as `schemas/`, for editors to validate a +// `bindings.yaml` or a `config.yaml` against. They are generated rather than +// committed so that they cannot drift from what wk itself enforces. +// +// `io: 'input'` describes what a user may write, which is what an editor needs: +// the output side has already had defaults filled in and `key` normalised. +const MAX_SAFE = Number.MAX_SAFE_INTEGER + +const options = { + io: 'input', + override: ( + { zodSchema, jsonSchema }: { zodSchema: z.core.$ZodType; jsonSchema: Record }, + ) => { + // zod stamps the registry id it used to name the `$defs` entry, and spells + // out the safe-integer bounds of every `z.int()`. Neither is worth + // publishing. + delete jsonSchema.id + if (jsonSchema.maximum === MAX_SAFE) delete jsonSchema.maximum + if (jsonSchema.minimum === -MAX_SAFE) delete jsonSchema.minimum + // `colors:`/`symbols:` written with nothing under them are an absence wk + // honours, but zod describes these wrappers by their output side alone. + if (nullableInputs.has(zodSchema)) jsonSchema.type = [jsonSchema.type, 'null'] + }, +} as const + +// Cleared first, so that a schema that was renamed or removed cannot ride +// along in the next tarball. +try { + Deno.removeSync('dist/schemas', { recursive: true }) +} catch { /* nothing to clear */ } +Deno.mkdirSync('dist/schemas', { recursive: true }) +for (const [name, schema] of [['bindings', BindingsSchema], ['config', ContextSchema]] as const) { + const path = `dist/schemas/${name}.json` + Deno.writeTextFileSync(path, `${JSON.stringify(z.toJSONSchema(schema, options), null, 2)}\n`) + console.log(`generated: ${path}`) +} diff --git a/src/main.ts b/src/main.ts index 23cc55c..36a1f3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { type KeyPressEvent } from '@cliffy/keypress' import { PRINTABLE_ASCII } from './const.ts' import { AbortError, KeyParseError, UndefinedKeyError } from './errors.ts' -import { type Binding, type Command } from './types/Binding.ts' +import { type Binding, type Command } from './schema.ts' export type Dependencies = { keypress: () => AsyncIterable diff --git a/src/run.ts b/src/run.ts index b5b51e7..c51a117 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,10 +1,9 @@ import { Command, EnumType } from '@cliffy/command' import { join as joinPath } from '@std/path' import { parse as parseYaml } from '@std/yaml' -import { Binding } from './types/Binding.ts' import { WK_CONFIG_HOME } from './const.ts' import { TUI } from './tui.ts' -import { defaultContext, mergeContext, PartialContext } from './types/Context.ts' +import type { Binding, ParseResult } from './schema.ts' import { Dependencies, main } from './main.ts' import { getKeySymbol, renderPrompt, renderTable } from './ui.ts' import { AbortError, ConfigError, KeyParseError, UndefinedKeyError } from './errors.ts' @@ -20,19 +19,10 @@ function summarize(e: unknown): string { return message.split('\n')[0].replace(/: \w+ '.*'$/, '').replace(/:$/, '') } -function isPartialContext(given: unknown): given is PartialContext { - return typeof given === 'object' && given !== null && !Array.isArray(given) -} - -function isBindings(given: unknown): given is Binding[] { - return Array.isArray(given) && - given.every((b) => typeof b === 'object' && b !== null && typeof (b as { key?: unknown }).key === 'string') -} - // A missing file is the only silent fallback. Anything else — a syntax error, a // shape mismatch, EACCES, EISDIR — stops wk, so that a typo cannot quietly // change how it behaves. -async function loadYaml(path: string, fallback: T, isValid: (given: unknown) => given is T): Promise { +async function loadYaml(path: string, fallback: T, parse: (given: unknown) => ParseResult): Promise { let text: string try { text = await Deno.readTextFile(path) @@ -56,11 +46,12 @@ async function loadYaml(path: string, fallback: T, isValid: (given: unknown) return fallback } - if (!isValid(parsed)) { - throw new ConfigError(path, 'invalid format') + const result = parse(parsed) + if (!result.ok) { + throw new ConfigError(path, result.reason) } - return parsed + return result.value } function abbreviateHome(path: string): string { @@ -89,10 +80,16 @@ For example, this simulates pressing "g", "p", and "f".`, .action(async ({ upOneLine, inputs }) => { // Read in a fixed order and one at a time, so that the first broken file is // the one reported and the rest are left untouched. + // Loaded here rather than at the top of the file: pulling in the schema + // costs a few milliseconds, and `wk init` — which runs from `.zshrc` on + // every new shell — has no configuration to validate. + const { defaultContext, parseBindings, parseContext } = await import('./schema.ts') + const load = async () => { - const ctx = mergeContext(await loadYaml(joinPath(WK_CONFIG_HOME, 'config.yaml'), {}, isPartialContext)) - const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), [], isBindings) - const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), [], isBindings) + const ctx = await loadYaml(joinPath(WK_CONFIG_HOME, 'config.yaml'), defaultContext, parseContext) + const empty: Binding[] = [] + const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), empty, parseBindings) + const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), empty, parseBindings) return [ctx, globalBindings.concat(localBindings)] as const } @@ -137,18 +134,11 @@ For example, this simulates pressing "g", "p", and "f".`, const delimiter = typeof definedDelimiter === 'string' ? definedDelimiter : ctx.outputDelimiter + // The schema has already narrowed every extra field to a string or a + // boolean, and a boolean interpolates as `true` / `false` on its own. const outputs = [delimiter, buffer] for (const [k, v] of Object.entries(rest)) { - switch (typeof v) { - case 'string': - outputs.push(`${k}:${v}`) - break - case 'boolean': - outputs.push(`${k}:${v ? 'true' : 'false'}`) - break - default: - break - } + outputs.push(`${k}:${v}`) } console.log(outputs.join(delimiter)) diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..fa8a356 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,245 @@ +import * as z from '@zod/mini' + +// The single source of truth for what a `config.yaml` and a `bindings.yaml` may +// contain. `schemas/*.json` in a release tarball is generated from here by +// `mise run generate:schemas`, and the types below are inferred from it, so +// there is only one place to change when a field is added or removed. +// +// Every message is the expectation on its own, because `run.ts` prints it after +// the path that located it: `: : expected ...`. `@zod/mini` ships no +// locale, so an expectation that is not spelled out here comes out as a bare +// `Invalid input`. +function expected(what: string) { + return { error: `expected ${what}` } +} + +function singleCharacter() { + const params = expected('a single character') + return z.string(params).check(z.length(1, params)) +} + +const aString = () => z.string(expected('a string')) + +// ---------------------------------------------------------------- bindings + +// A key is a string, but YAML turns an unquoted digit into a number and `1` is +// a natural thing to bind, so take those too and normalise them back. The +// punctuation that YAML reads as null (`~`, `!`, `?`, `#`) cannot be recovered +// this way and still has to be quoted. +const aKey = expected('a key name or a digit 0-9') + +const KeySchema = z.union([ + z.string().check(z.minLength(1, aKey)), + z.pipe(z.int().check(z.minimum(0), z.maximum(9)), z.transform(String)), +], aKey) + +// The extra keys are the `key:value` pairs of the output protocol, so anything +// the widget can carry — a string or a boolean — is allowed through. +const CommandSchema = z.catchall( + z.object({ + key: KeySchema, + desc: z.optional(aString()), + icon: z.optional(aString()), + type: z.literal('command'), + buffer: aString(), + delimiter: z.optional(singleCharacter()), + // Named rather than left to the catchall, so that the published schema can + // offer the two flags `widget.eta` actually acts on. + eval: z.optional(z.boolean(expected('a boolean'))), + accept: z.optional(z.boolean(expected('a boolean'))), + }), + z.union([z.string(), z.boolean()], expected('a string or a boolean')), +) + +const GroupSchema = z.strictObject({ + key: KeySchema, + // Unlike a command, a group has no buffer to fall back on when drawing. + desc: aString(), + icon: z.optional(aString()), + type: z.literal('bindings'), + get bindings() { + return z.array(BindingSchema, expected('a list of bindings')) + .check(z.minLength(1, expected('at least one binding'))) + }, +}, { + error: (issue) => + issue.code === 'unrecognized_keys' + ? `unknown field ${issue.keys.map((key) => `"${key}"`).join(', ')}` + : 'expected a group', +}) + +const BindingSchema: z.ZodMiniType = z.discriminatedUnion( + 'type', + [CommandSchema, GroupSchema], + expected('type "command" or "bindings"'), +) +z.globalRegistry.add(BindingSchema, { id: 'Binding' }) + +export const BindingsSchema = z.array(BindingSchema, expected('a list of bindings')) + +export type Command = z.infer +export type Group = Omit, 'bindings'> & { bindings: Binding[] } +export type Binding = Command | Group + +// ---------------------------------------------------------------- config + +// Every branch reports the same expectation, so that a number out of range +// reads as a bad colour rather than as a failed bound. +const aColor = expected('a color') + +const ANSI_COLOR = [ + z.int(aColor).check(z.minimum(-1, aColor), z.maximum(255, aColor)), + z.string(aColor).check(z.regex(/^#[0-9a-fA-F]{6}$/, aColor)), +] as const + +const ColorSchema = z.union([ + ...ANSI_COLOR, + z.object({ + color: z.union([...ANSI_COLOR], aColor), + attrs: z.array( + z.enum(['bold', 'dim', 'italic', 'underline', 'inverse', 'hidden', 'strikethrough']), + expected('a list of attributes'), + ).check(z.minLength(1, expected('at least one attribute'))), + }), +], aColor) +z.globalRegistry.add(ColorSchema, { id: 'Color' }) + +const aMapping = expected('a mapping') + +// YAML reads `colors:` with nothing under it as null, which means the same as +// not writing the key at all — the fold `loadYaml` already applies to a whole +// document that parses to null. Only the containers get it: an empty +// `outputDelimiter:` is a value that cannot work, not an absence. +// +// The casts are only about `z.transform` widening its output past `schema`'s +// input. The pipe hands the value straight through, so the wrapper keeps +// `schema`'s own type, including whether the field is optional. +// +// A pipe that starts with a transform has no input schema to describe, so +// `z.toJSONSchema` falls back to the output side and the published schema would +// say `null` is invalid. `nullableInputs` marks the wrappers so that +// `generate-schemas.ts` can put it back; keeping the union out of the runtime +// schema is what keeps a bad field reported as `colors.prompt: expected a +// color` instead of collapsing to `colors: expected a mapping`. +export const nullableInputs = z.registry>() + +function orAbsent(schema: T): T { + const wrapper = z.pipe( + z.transform((given: unknown) => given ?? undefined), + schema as unknown as z.ZodMiniType | undefined>, + ) as unknown as T + nullableInputs.add(wrapper, {}) + return wrapper +} + +const DEFAULT_KEY_SYMBOLS: Record = { + space: '␣', + return: '⏎', + tab: '⇥', + up: '↑', + down: '↓', + right: '→', + left: '←', + home: '⇱', + end: '⇲', + pageup: '⇞', + pagedown: '⇟', + insert: '⎀', + delete: '⌦', + F1: '󱊫', + F2: '󱊬', + F3: '󱊭', + F4: '󱊮', + F5: '󱊯', + F6: '󱊰', + F7: '󱊱', + F8: '󱊲', + F9: '󱊳', + F10: '󱊴', + F11: '󱊵', + F12: '󱊶', +} + +export const ContextSchema = z.looseObject({ + outputDelimiter: z._default(singleCharacter(), '\t'), + // Capped because a delay past 2^31-1 wraps around in V8 and fires almost + // immediately, which looks like the menu closing on its own. + timeout: z._default( + z.int(expected('a whole number of milliseconds')).check( + z.minimum(0, expected('0 or more')), + z.maximum(300_000, expected('300000 (5 minutes) or less')), + ), + 0, + ), + symbols: orAbsent( + z.prefault( + z.looseObject({ + // U+F460 is a Nerd Font glyph; spelled out so that it survives editing. + prompt: z._default(aString(), '\uF460 '), + breadcrumb: z._default(aString(), ' » '), + separator: z._default(aString(), '➜'), + group: z._default(aString(), '+'), + // Merged into the built-in symbols rather than replacing them, so that + // naming one key does not blank out the rest. + keys: orAbsent( + z.pipe( + z.optional(z.record(z.string(), aString(), expected('a mapping of key names to symbols'))), + z.transform((given) => ({ ...DEFAULT_KEY_SYMBOLS, ...given })), + ), + ), + }, aMapping), + {}, + ), + ), + colors: orAbsent( + z.prefault( + z.looseObject({ + prompt: z._default(ColorSchema, 8), + breadcrumb: z._default(ColorSchema, { color: 8, attrs: ['dim'] }), + separator: z._default(ColorSchema, { color: 8, attrs: ['dim'] }), + group: z._default(ColorSchema, 8), + inputKeys: z._default(ColorSchema, 8), + lastInputKey: z._default(ColorSchema, -1), + bindingKey: z._default(ColorSchema, -1), + bindingIcon: z._default(ColorSchema, 8), + bindingDescription: z._default(ColorSchema, 8), + }, aMapping), + {}, + ), + ), +}, aMapping) + +export type Color = z.infer +export type Context = z.infer + +export const defaultContext: Context = ContextSchema.parse({}) + +// ---------------------------------------------------------------- parsing + +export type ParseResult = { ok: true; value: T } | { ok: false; reason: string } + +// The first issue is the one reported: only a single line fits `zle -M`. +function toReason(error: { issues: readonly { path: PropertyKey[]; message: string }[] }): string { + const issue = error.issues[0] + const path = issue.path + .map((segment) => typeof segment === 'number' ? `[${segment}]` : `.${String(segment)}`) + .join('') + .replace(/^\./, '') + return path === '' ? issue.message : `${path}: ${issue.message}` +} + +export function parseBindings(given: unknown): ParseResult { + const result = BindingsSchema.safeParse(given) + if (!result.success) { + return { ok: false, reason: toReason(result.error) } + } + // `BindingSchema` is annotated as an opaque `ZodMiniType` to break the + // recursion between a group and its own items, so what comes back is untyped + // even though it matched. + return { ok: true, value: result.data as Binding[] } +} + +export function parseContext(given: unknown): ParseResult { + const result = ContextSchema.safeParse(given) + return result.success ? { ok: true, value: result.data } : { ok: false, reason: toReason(result.error) } +} diff --git a/src/types/Binding.ts b/src/types/Binding.ts deleted file mode 100644 index d0970c6..0000000 --- a/src/types/Binding.ts +++ /dev/null @@ -1,21 +0,0 @@ -type Base = { - key: string - desc?: string - icon?: string -} - -export type Command = Base & { - type: 'command' - buffer: string - [key: string]: string | boolean -} - -type Bindings = Base & { - type: 'bindings' - desc: string - bindings: TmpBinding[] -} - -type TmpBinding = Command | Bindings - -export type { TmpBinding as Binding } diff --git a/src/types/Context.ts b/src/types/Context.ts deleted file mode 100644 index 2442e16..0000000 --- a/src/types/Context.ts +++ /dev/null @@ -1,125 +0,0 @@ -type ANSIColor = number | string - -type ColorAttribute = 'bold' | 'dim' | 'italic' | 'underline' | 'inverse' | 'hidden' | 'strikethrough' - -export type Color = ANSIColor | { color: ANSIColor; attrs: [ColorAttribute, ...ColorAttribute[]] } - -export type Context = { - outputDelimiter: string - timeout: number - symbols: { - prompt: string - breadcrumb: string - separator: string - group: string - keys: Record - } - colors: { - prompt: Color - breadcrumb: Color - separator: Color - group: Color - inputKeys: Color - lastInputKey: Color - bindingKey: Color - bindingIcon: Color - bindingDescription: Color - } -} - -export type PartialContext = { - outputDelimiter?: string - timeout?: number - symbols?: { - prompt?: string - breadcrumb?: string - separator?: string - group?: string - keys?: Record - } - colors?: { - prompt?: Color - breadcrumb?: Color - separator?: Color - group?: Color - inputKeys?: Color - lastInputKey?: Color - bindingKey?: Color - bindingIcon?: Color - bindingDescription?: Color - } -} - -export const defaultContext: Context = { - outputDelimiter: '\t', - timeout: 0, - symbols: { - prompt: ' ', - breadcrumb: ' » ', - separator: '➜', - group: '+', - keys: { - space: '␣', - return: '⏎', - tab: '⇥', - up: '↑', - down: '↓', - right: '→', - left: '←', - home: '⇱', - end: '⇲', - pageup: '⇞', - pagedown: '⇟', - insert: '⎀', - delete: '⌦', - F1: '󱊫', - F2: '󱊬', - F3: '󱊭', - F4: '󱊮', - F5: '󱊯', - F6: '󱊰', - F7: '󱊱', - F8: '󱊲', - F9: '󱊳', - F10: '󱊴', - F11: '󱊵', - F12: '󱊶', - }, - }, - colors: { - prompt: 8, - breadcrumb: { - color: 8, - attrs: ['dim'], - }, - separator: { - color: 8, - attrs: ['dim'], - }, - group: 8, - inputKeys: 8, - lastInputKey: -1, - bindingKey: -1, - bindingIcon: 8, - bindingDescription: 8, - }, -} - -export function mergeContext(userDefinedContext: PartialContext): Context { - return { - ...defaultContext, - ...userDefinedContext, - symbols: { - ...defaultContext.symbols, - ...userDefinedContext.symbols, - keys: { - ...defaultContext.symbols.keys, - ...userDefinedContext.symbols?.keys, - }, - }, - colors: { - ...defaultContext.colors, - ...userDefinedContext.colors, - }, - } -} diff --git a/src/ui.ts b/src/ui.ts index 1176ea5..5a69cd0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,7 +1,6 @@ import { bold, dim, hidden, inverse, italic, rgb24, rgb8, strikethrough, underline } from '@std/fmt/colors' import { border as defaultBorder, Table } from '@cliffy/table' -import { type Binding } from './types/Binding.ts' -import { type Color, type Context } from './types/Context.ts' +import { type Binding, type Color, type Context } from './schema.ts' function color(text: string, givenColor: Color) { const ansi256 = (typeof givenColor === 'number' || typeof givenColor === 'string') ? givenColor : givenColor.color From c10dc6b0df3ecea334e2b4c47172c3d1f64c96ac Mon Sep 17 00:00:00 2001 From: 844196 <844196@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:33:29 +0900 Subject: [PATCH 2/2] chore: Point the agent docs at the generated schema Co-Authored-By: Claude Opus 5 --- .claude/skills/run-wk/SKILL.md | 18 +++++++++--------- .claude/skills/run-wk/driver.sh | 7 ++++--- CLAUDE.md | 14 ++++++++------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.claude/skills/run-wk/SKILL.md b/.claude/skills/run-wk/SKILL.md index 461a7d4..3e6ec0a 100644 --- a/.claude/skills/run-wk/SKILL.md +++ b/.claude/skills/run-wk/SKILL.md @@ -41,9 +41,9 @@ stdout: \t\tgit push **1 つ目のフィールドは区切り文字そのもの**なので、buffer の前に区切り文字が 2 個並ぶ。`src/run.ts` が `[delimiter, buffer, ...].join(delimiter)` を出しており、1 個目はフィールドの中身、2 個目は join の区切り。受け側の `src/widget.eta` が `${res:2}` で捨てているのはこの 2 つ。末尾に改行が 1 個付く (`console.log` 由来、driver の `stdout:` 行では落としている)。 -**`key:value` の並びは YAML の記述順**。`src/run.ts` が `...rest` を `Object.entries()` で回すため、`accept` を先に書けば `accept:true` が先に出る。受け側が並び順を前提にすると壊れる。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。 +**`key:value` の並び順は保証しない。** `src/run.ts` は `...rest` を `Object.entries()` で回すが、その `rest` は zod が組み直したオブジェクトなので、schema が名前で持つ `eval` / `accept` が schema の順で先に出て、それ以外の追加フィールドがファイル順で続く (`accept` を先に書いても `eval:true accept:true` の順)。受け側は位置ではなく `key:` で引くこと — `src/widget.eta` の `reply[(rb:2:)eval:*]` がそうしている。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。 -**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`schemas/bindings.json`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。 +**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`src/schema.ts`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。 既定フィクスチャの区切り文字はタブなので、`\t\t` を見ても「1 つ目のフィールドだから 2 個」なのか「buffer にタブがある」のか区別できない。撃ち分けるには `delimiter: '|'` を持つバインディングを `$SP` に置く (`stdout: ||echo hi|eval:true` のように先頭 2 文字も追従する)。生バイトは `TMPDIR=$SP WK_KEEP=1` で残るサンドボックスの `out` / `err` / `status` で見る (パスは stderr に出る)。 @@ -72,7 +72,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s パンくずに並ぶのは desc ではなく**押したキー**。**`type: command` を選ぶと `wk run` が終了する = ペインも消える**ので、リーフまで降りると画面は残らない。これは成功で、出力は `keys` で見る。 -画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/types/Context.ts` の `defaultContext`)。 +画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/schema.ts` の `defaultContext`)。 | 画面上 | `symbols` のキー | 既定値 | 出る場所 | |---|---|---|---| @@ -88,7 +88,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s `timeout` のように**時間で消えるものは、キーごとの待ち (`WK_SETTLE`、既定 0.4 秒) より短ければ写らない。** 消えたのか出なかったのかは `keys` に流せば分かる (exit 4)。 -driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は下の 3 つと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys ` を送れば応答する。 +driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は「弾かれる入力・落ちる入力」の節のものと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys ` を送れば応答する。 ## widget — zsh の BUFFER まで @@ -128,13 +128,13 @@ $ WK_LEADER='^O' WK_PRETYPE=',' ./.claude/skills/run-wk/driver.sh widget t BUFFER=[make test] CURSOR=[9] ``` -## 落ちる入力 +## 弾かれる入力・落ちる入力 -どれも exit 1 で、`screen` では `wk exited before drawing a menu` に見える。 +**設定ファイルの不備は落ちずに exit 7 になる。** stderr は `<ファイル>: <パス>: <期待>` の 1 行で、パスが悪いフィールドまで案内する (`.../bindings.yaml: [0].bindings[0].key: expected a key name or a digit 0-9`)。`screen` からは `wk exited before drawing a menu` に見えるだけなので、**原因は `keys` に流して stderr を読む。** 配列でない `bindings.yaml`、`desc` の無い `type: bindings`、`bindings:` にスカラを置いたグループ、`buffer: 42`、`colors.prompt: {}`、`outputDelimiter: 42` — 描画時にスタックトレースを吐いていた入力は全部ここに畳まれている。グローバルとローカルで症状が変わることも無い。 -- **配列でない `bindings.yaml`** (スカラや mapping) — **グローバルとローカルで症状が違う。** `src/run.ts` は `globalBindings.concat(localBindings)` の形なので、グローバル側が配列でなければ `s.concat is not a function` で即死する。ローカル側は `concat()` の引数なので**落ちずに 1 要素として連結され**、描画時に `Cannot read properties of undefined (reading 'replace')` になる。パースは通るため `.catch()` はどちらでも発火しない。 -- **`desc` の無い `type: bindings`** — `Cannot read properties of undefined (reading 'replace')`。スキーマ違反として弾かれるのではなく**そのグループを描く瞬間に落ちる**ので、最上段なら起動直後、下の階層ならそこへ降りたとき。フィクスチャの必須フィールドは `key` / `type` / `buffer` の 3 つだけ (`schemas/bindings.json`) だが、グループの `desc` はこの通り実質必須。**この `reading 'replace'` は 1 つ上の「配列でないローカル `wk.bindings.yaml`」と同じ字面**なので、どちらか決め打ちせず両方のファイルを見る。 -- **`--inputs ' '`** — 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。 +**`key` はクォート無しの数字でも通る。** YAML が数値にした `0`〜`9` は wk が文字列に戻す。**クォートが要るのは YAML のインジケータ文字だけ** — `.` / `$` / `(` / `)` / `+` / `/` / `;` / `<` / `=` / `\` / `^` / `_` はそのまま通る。クォート無しだと `~` / `!` / `?` / `#` は null になって wk が弾き、`"` / `%` / `&` / `'` / `*` / `,` / `-` / `:` / `@` / `[` / `]` / `` ` `` / `{` / `}` は YAML 側の構文エラー、`>` / `|` はブロックスカラ扱いで空文字列になって wk が弾く。どれも exit 7 だが、構文エラーの文言だけパーサ由来。 + +**exit 1 で落ちるのは `--inputs ' '` だけ。** 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。 **空ファイルは落ちない。** 空ドキュメント (0 バイト・改行だけ・空白だけ・コメントだけ・`---`・`null`・`~`) は `src/run.ts` の `loadYaml()` が不在ファイルと同じ扱いに畳む。bindings なら全キー未定義 = exit 5、config なら既定値。 diff --git a/.claude/skills/run-wk/driver.sh b/.claude/skills/run-wk/driver.sh index 8f4faee..74d1fde 100755 --- a/.claude/skills/run-wk/driver.sh +++ b/.claude/skills/run-wk/driver.sh @@ -39,7 +39,7 @@ # WK_SETTLE seconds to wait after each key (default: 0.4 screen, 0.6 widget) # # Exit status: -# `keys` exits with wk's own exit code (0/1/2/3/4/5/6, or 124 when wk was +# `keys` exits with wk's own exit code (0/1/2/3/4/5/6/7, or 124 when wk was # still waiting for a key), so `driver.sh keys g p || ...` works from a # script. 1 is an uncaught error and is reachable from ordinary fixtures. # The other subcommands exit 0 on success. A driver-level failure — no binary, @@ -249,7 +249,7 @@ wait_for_screen() { # capture-pane strips trailing whitespace and tmux has already resolved the ANSI # colours away. The first line looks empty but is not: the default prompt symbol -# in src/types/Context.ts is a Nerd Font glyph (U+F460), which most terminals and +# in src/schema.ts is a Nerd Font glyph (U+F460), which most terminals and # every grep-based assertion render as nothing useful. Match on a binding row # instead of the prompt. # The widget layer's startup marker (below) is scaffolding, not evidence; drop @@ -375,12 +375,13 @@ cmd_keys() { explain_exit() { case "$1" in 0) echo '(selected a command)' ;; - 1) echo '(uncaught error — read stderr; a non-array bindings.yaml and a blank --inputs both land here)' ;; + 1) echo '(uncaught error — read stderr; a blank --inputs lands here)' ;; 2) echo '(bad CLI arguments)' ;; 3) echo '(abort: escape / ctrl-c / ctrl-d / backspace at root)' ;; 4) echo '(timeout — config.yaml timeout elapsed)' ;; 5) echo '(undefined key)' ;; 6) echo '(key parse failure)' ;; + 7) echo '(bad config.yaml or bindings.yaml — stderr names the file and the field)' ;; 124) echo '(driver timeout: wk was still waiting for a key)' ;; *) echo '(unknown)' ;; esac diff --git a/CLAUDE.md b/CLAUDE.md index a592478..2932bd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,17 +10,19 @@ zsh向けのwhich-keyライクメニュー。Deno製CLI (`src/wk.ts`) と、そ - 終了コード: 0成功/3中断/4タイムアウト/5未定義キー/6キーパース失敗/7設定ファイル不正。`widget.eta` の `case` がこれで分岐し、それ以外は `zle -M` でエラー表示に回る。 - エラー種別を増やすときは `src/errors.ts`・`run.ts` のcatch・`widget.eta` のcaseをセットで触る。 -## binding/config のスキーマは 3 箇所にある +## スキーマは `src/schema.ts` が SSoT -`key`/`desc`/`buffer` などのフィールドを増減させたら、次の3箇所を揃える。 +`key`/`desc`/`buffer` などのフィールドを増減させたら、触るのは `src/schema.ts` と `README.md` の設定例だけ。 -- `src/types/Binding.ts`, `src/types/Context.ts` (型と既定値) -- `schemas/bindings.json`, `schemas/config.json` (ユーザー向けJSON Schema) -- `README.md` の設定例 +- `src/schema.ts` — `@zod/mini` のスキーマ。型 (`Binding`/`Command`/`Context`/`Color`) は `z.infer` で導出、既定値は `z._default`/`z.prefault` が持ち、`defaultContext` は `ContextSchema.parse({})`。手書きの型定義は無い。 +- 配布用JSON Schemaはコミットしていない。`mise run generate:schemas` が `dist/schemas/` に吐き、`mise run dist ` がリリースtarball (`wk-/{wk,LICENSE,schemas/}`) に詰める。生成は `io: 'input'` — ユーザーが書ける形を出す。 +- `@zod/mini` はロケールを積まないので、`error` を渡し忘れた検査は `Invalid input` になる。フィールドを足したら必ず `expected(...)` を渡し、既存の入力を一通り流して `Invalid input` が出ないことを見る。 +- 設定エラーは `<ファイル>: <パス>: <期待>` の1行で exit 7。パスはzodのissueの `path` から `[0].bindings[0].key` の形に組む。 +- `key` は文字列だが、YAMLがクォート無しの数字を数値にするため `0`〜`9` の整数も受けて `String()` で正規化する。クォートが要るのはYAMLのインジケータ文字だけ (nullになるか構文エラー) で、`.` のような大半の記号はそのまま通る。 ## 検証 -静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc ` — import map がここにあるので、渡さないと `@cliffy/*` が解決できずに落ちる。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。 +静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc ` — import map がここにあるので、渡さないと `@cliffy/*` や `@zod/mini` が解決できずに落ちる。`deno.jsonc` は `lock.frozen` なので、依存を足したら `deno install --frozen=false`。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。 e2eテストが `e2e/` にある。`mise run e2e` でバイナリをビルドしてから走らせ、`mise run e2e:only` は `dist/` の既存バイナリをそのまま使う (パスを渡せば1ファイルだけ — `mise run e2e:only e2e/tests/06_widget.bats`)。`exec format error` は `WK_E2E_TARGET` とバイナリのアーキ不一致。Docker (zsh/tmux/bats-core、Denoは入れない) の中でbats-coreを回し、対象バイナリは `WK_BIN` で受け取る黒箱テスト。将来Denoをやめても受け入れ仕様として使い回せるよう、テスト側からDenoを参照しないこと。層は3つ — `script(1)` でptyを張ってCLIを直叩き (`helpers/common.bash` の `wk_run`)、TTY不要の `wk init`、tmuxで実zshウィジェットを動かす (`helpers/tmux.bash`)。ユニットテストは無い。