diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..af9f8b5 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + + +@../AGENTS.md + diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cca6c4b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "kit context --claude" + } + ] + } + ] + }, + "permissions": { + "deny": [ + "Bash(git rebase *)", + "Bash(git worktree add *)", + "Bash(git checkout -b *)", + "Bash(git switch -c *)", + "Bash(git worktree remove *)", + "Bash(git branch -D *)", + "Bash(git checkout main *)", + "Bash(git switch main *)", + "Bash(git reset --hard *)", + "Bash(git push --force *)", + "Bash(git push -f *)" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f5a3108 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# .editorconfig - see: https://editorconfig.org +# ---------------------------------------------------------------------------------------------------------------------- + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# a trailing double space is a hard break +[*.md] +trim_trailing_whitespace = false + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..96e3e74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# .gitattributes - how git and the forge treat paths +# ---------------------------------------------------------------------------------------------------------------------- + +# mise writes these lock files; marked generated, the forge folds their diffs, and kit lint holds them to no template. +/.mise/locks/** linguist-generated + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4fa4348 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# dependabot.yml - keeps the SHA-pinned actions in the workflows current +# ---------------------------------------------------------------------------------------------------------------------- +# +# Every `uses:` in .github/workflows/ is pinned to a full commit SHA with the version as a trailing comment, and a +# SHA never advances on its own. Dependabot's github-actions ecosystem opens a pull request that moves the SHA and +# the comment together, weekly. The tools come from mise.toml, which Dependabot does not read; `mise outdated` +# answers for those. +# +# see: https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/workflows/kit-ci.yml b/.github/workflows/kit-ci.yml new file mode 100644 index 0000000..61fe5a8 --- /dev/null +++ b/.github/workflows/kit-ci.yml @@ -0,0 +1,82 @@ +# kit-ci.yml - the check the kit delivers for GitHub Actions: kit ci on every pull request into the default branch +# ---------------------------------------------------------------------------------------------------------------------- +# +# Runs one job, CI, on each pull request into the default branch. CI is the check name a ruleset requires. The job: +# +# 1. puts mise on the runner, when the repository has a mise.toml (brew and docker are already on GitHub's +# runners) +# 2. puts kit on PATH: the release .kit/manifest records, downloaded from its public URL, or this checkout's own +# bin/ when the manifest records unreleased +# 3. installs the gate's tools with `kit toolchain install`, whichever of mise, brew, or docker manages them +# 4. runs `kit ci`: the gate, `kit check`, `kit linked`, and `kit policy`, and reports every result +# +# Delivered by kit, not edited by hand. A change is a new release, then kit update. runs-on is the ci_runner value +# under answers: in .kit/settings.yml. +# +# see: .kit/1st.md + +name: kit + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + CI: + if: github.event.pull_request.base.ref == github.event.repository.default_branch + runs-on: "ubuntu-latest" + env: + # mise also reads mise.ci.toml, on top of mise.toml, for the tools only a runner needs. + MISE_ENV: ci + steps: + - name: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # kit policy reads every commit since the branch forked from the default branch. + fetch-depth: 0 + + - name: mise-on-path + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + if: ${{ hashFiles('mise.toml', '.mise.toml') != '' }} + with: + install: false + + - name: kit-on-path + run: | + set -o errexit -o nounset -o pipefail + if [[ ! -f .kit/manifest ]] + then + echo '::error::run kit install and commit what it writes; this repository has no .kit/manifest' + exit 1 + fi + version=$(sed -n 's/^version //p' .kit/manifest) + if [[ "${version}" == unreleased && -x bin/kit ]] + then + printf '%s/bin\n' "${PWD}" >> "${GITHUB_PATH}" + elif [[ "${version}" == unreleased ]] + then + echo '::error::.kit/manifest says unreleased and there is no bin/kit; pin a release and run kit update' + exit 1 + elif ! command -v kit >/dev/null + then + mkdir -p "${RUNNER_TEMP}/kit" + curl --fail --silent --location \ + "https://github.com/empowerite/kit/releases/download/release/${version}/kit-${version}.tar.gz" \ + | tar --extract --gzip --directory "${RUNNER_TEMP}/kit" --strip-components 1 + printf '%s/kit/bin\n' "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + fi + + - name: toolchain + run: kit toolchain install && kit toolchain path >> "${GITHUB_PATH}" + + - name: kit-ci + env: + GH_TOKEN: ${{ github.token }} + run: kit ci + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97da27c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +# release.yml - pushing a release tag publishes the kit as a GitHub release +# ---------------------------------------------------------------------------------------------------------------------- +# +# Pushing a tag such as release/0.1.0 runs this workflow. It has two steps, and each step is one kit command, so +# the same thing can be run on a laptop: +# +# kit release verify checks the tag. A release tag, release/0.1.0, must point at a commit on main. A +# pre-release tag, release/0.1.0-rc.1, must point at a commit that is not on main. Any +# other tag is refused. +# kit release publish packs this checkout into kit-.tar.gz, writes the version after release/ into +# it, and publishes it as a GitHub release named by that version. A version with a +# suffix is published as a pre-release. +# +# GitHub does not let a published release change, so a version is tagged once and never again. A release is +# published here and nowhere else: kit release publish refuses a release tag anywhere but on the runner, so a +# desk cannot publish one by hand. A desk may publish a pre-release. A pre-release is deleted when the branch it +# was tagged on is deleted; see retire-pre-releases.yml. +# +# see: bin/kit-release.sh + +name: release + +on: + push: + tags: ['release/*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: verify + run: bin/kit release verify "${GITHUB_REF_NAME}" + + - name: publish + env: + GH_TOKEN: ${{ github.token }} + run: bin/kit release publish "${GITHUB_REF_NAME}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.github/workflows/retire-pre-releases.yml b/.github/workflows/retire-pre-releases.yml new file mode 100644 index 0000000..e92e684 --- /dev/null +++ b/.github/workflows/retire-pre-releases.yml @@ -0,0 +1,33 @@ +# retire-pre-releases.yml - deleting a branch deletes the pre-releases that were made from it +# ---------------------------------------------------------------------------------------------------------------------- +# +# Deleting a branch on GitHub runs this workflow. Its one step is one kit command: kit release retire deletes +# every pre-release, tag included, whose commit is no longer on any branch. A release is never deleted, because +# it is on main and main is never deleted. Deleting a tag also sends a delete event, and that one is ignored. +# +# see: bin/kit-release.sh, .github/workflows/release.yml + +name: retire-pre-releases + +on: + delete: + +permissions: + contents: write + +jobs: + retire: + if: github.event.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: retire + env: + GH_TOKEN: ${{ github.token }} + run: bin/kit release retire + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91c9424 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# .gitignore - untracked files and folders +# ---------------------------------------------------------------------------------------------------------------------- + +# kit:generate .gitignore +# per-issue worktrees for agents; see: .kit/1st.md +/worktrees/ +# kit:end .gitignore + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/1st.md b/.kit/1st.md new file mode 100644 index 0000000..a55c780 --- /dev/null +++ b/.kit/1st.md @@ -0,0 +1,175 @@ + +# Read this first + +This file is the kit's entry point. Every agent follows it in every session, in any repository that has a `.kit/` +directory. The repository's own rules in `AGENTS.md` apply as well. Where the two differ, `AGENTS.md` wins. + +## Integrity + +1. Report only what happened. Write "done", "passing", or "verified" only for something that ran and succeeded. +2. Report a skip as a skip. A check that was not run, is not implemented, or cannot be implemented is reported as + exactly that. It passes no gate and earns no credit. +3. Give the evidence for every claim, or say there is none. Name the command that ran and what it printed. +4. Being wrong is recoverable. Misreporting is not. +5. Rules live in the repository, never in a session's memory. When you learn a convention or receive a correction, + write it down, through the workflow, in one of these places, where every agent and every person reads it: + - `AGENTS.md` + - a module of the repository's own under `.kit/modules/` + - a kit release + +## The root checkout is the human's desk + +The desk is the root checkout on the human's machine, on whatever branch it currently has. It belongs to the human. +They type, review, and leave work in progress there. It usually has `main` checked out, and may have any branch that +no worktree has checked out. + +An agent never checks out, resets, rebases, or otherwise changes the desk in place. An agent works in a worktree +under `worktrees/`, one per issue. Work reaches `main` only through a merged pull request. + +## The workflow + +1. Start from an issue. A change without an issue has not started. +2. `kit issue ` creates the following, and never switches the checkout you run it from: + - the worktree `worktrees/-` + - a branch, linked to the issue under Development + - a draft pull request +3. Work in that worktree. Sign every commit. Write each commit subject as `# : `. Run + `kit rebase` to bring the branch up to date with the default branch and push it. +4. Run `gh pr ready` when the work is done. The pull request body is the report. It states: + - what changed + - what was checked, and how + - what was left out, and why +5. A human merges. Nothing reaches `main` any other way. Do not call anything merged until you see the merge. + +## Kit commands instead of git commands + +Some git commands have a kit replacement that adds the check its rule requires. An agent uses only the kit +command. A harness with a deny list refuses the raw git command. + +- `kit rebase`, never `git rebase`. `kit rebase` refuses to run in the root checkout or on the default branch. + Otherwise it rebases the worktree's branch onto the default branch at origin and pushes it. +- `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. +- `kit tidy`, never `git worktree remove` or `git branch -D`. +- An agent never switches or resets the desk. `git checkout main`, `git switch main`, `git reset --hard`, and + `git push --force` are refused as well. + +These limits do not apply to the human at the desk. + +## Plans are prose + +Write a design, a spec, or a plan as prose in the body of an issue. Never commit it as a file. When the design is +written, the issue is the deliverable, and the workflow above starts from it. + +## The worker contract + +A worker is whoever implements one issue, in the worktree named for that issue. + +1. `cd` into the worktree named in the task. Confirm that `git rev-parse --abbrev-ref HEAD` prints a branch name + that begins with `-`. +2. Run every command from there. Never touch the root checkout. +3. Never run `kit issue`, create a branch, or create a worktree. These were done before the handoff. +4. Implement what the issue asks and nothing else. +5. Push as you go. +6. Before you call anything done, run the repository's gate (its own CI command, such as `just ci`) through + the pinned toolchain. +7. Finish with `gh pr ready`, with the report in the pull request body. +8. If the gate fails or the work is partial, leave the pull request as a draft and say why. +9. Never merge. +10. Report what ran and what it printed. Nothing more. + +## Rules by file type + +Each kind of file has a rule file under `.kit/rules/`. The frontmatter at the top of a rule file has these keys: + +| Key | Contains | +| --- | --- | +| `paths:` | Globs matched against a file's path from the repository root. The rule governs a file that matches. | +| `content:` | Patterns matched against a file's first line. The rule governs a file that matches. | +| `lint:` | The linter that `kit lint` runs over the files the rule governs. | +| `templates:` | The shapes a file of this kind can take. The templates are under `.kit/templates/`. | + +Before you change a file: + +1. Run `kit context `. It prints the rules that govern the file, and records that you asked. +2. Read every rule it prints. + +No harness keeps a copy of the rules. `kit context` is how every harness, and every person, finds them. + +Create a new file with `kit new file `, giving the path from the repository root. Never write a new file +from nothing. `kit new file`: + +- writes the file from its rule's template +- fills the template's holes that the path supplies +- refuses a hole you left empty, and names the flag that fills it, such as `--purpose=<…>` or `--title=<…>` +- prints the rules to read before you write the rest of the file + +Two flags choose the template: + +- `--variant=<…>` picks another of the rule's shapes, such as a sourced bash module. +- `--rule=<…>` names the rule for a path with no extension. + +`kit lint` fails any file, including one written by hand, whose shape matches none of its rule's templates. + +| Kind | Rule | +| --- | --- | +| Markdown | [rules/markdown.md](rules/markdown.md) | +| Bash | [rules/bash.md](rules/bash.md) | +| Just | [rules/just.md](rules/just.md) | +| YAML | [rules/yaml.md](rules/yaml.md) | +| Workflows | [rules/workflows.md](rules/workflows.md) | +| JSON | [rules/json.md](rules/json.md) | +| Ignore lists | [rules/gitignore.md](rules/gitignore.md) | + +## What the kit delivers + +The repository contains the text: + +- this file +- the rules under `.kit/rules/` +- `.kit/manifest`, which records the release version, the active modules, and a checksum for each delivered file + +The installed release contains the code. You run it through the `kit` command on `PATH`. The workflow commands +are `kit issue`, `kit rebase`, `kit tidy`, `kit status`, `kit context`, `kit new file`, and `kit lint`. Run them +exactly as written here, and in no other way. + +Everything the release delivers belongs to a module. A repository activates the modules it wants: + +- `kit install ci-github lefthook claude` activates modules when the kit is installed. +- `kit module add claude` activates a module later. +- `kit module remove` deactivates a module. + +A module of the repository's own lives under `.kit/modules/` and is activated the same way. + +How a delivered file is placed depends on its destination: + +- A `.json` file, other than a schema, is merged by key. The kit owns only the entries it writes. +- A region inside a file the repository owns starts at a `kit:generate` directive. `kit generate` fills the region + by running the command the directive names. +- Every other file is copied as it is. + +The modules: + +- `kit`: this file, the rules, and their templates, and two regions that `kit region` fills: + - the sentence in the repository's `AGENTS.md` that points to this file + - the `/worktrees/` entry in the repository's `.gitignore` +- `lefthook`: `.kit/lefthook.yml`, a pre-commit hook that runs `kit lint` and `kit generate --check` over the + staged paths. A repository's `lefthook.yml` includes it with `extends` and adds its own commands. `kit lint` + does not need a hook manager: it runs each rule's linter over the paths the rule governs. +- `ci-github`: `.github/workflows/kit-ci.yml`, one GitHub Actions job. The job installs the gate's tools with + `kit toolchain install`, then runs `kit ci`. `kit ci` runs four checks, and runs the same four on any machine: + - the repository's gate + - `kit check` + - `kit linked`, which refuses a change that is not linked to an open issue + - `kit policy`, which refuses a branch name, commit subject, or title that breaks the repository's policy +- `policy-opa`: `.kit/policy/kit.rego`, the naming policy in Rego. With it, `kit policy` runs on Open Policy + Agent, and a rule can express what a pattern cannot. +- `claude`: support for Claude Code, which reads `AGENTS.md` only when it finds no `CLAUDE.md`. It delivers: + - a region in `.claude/CLAUDE.md` that imports `AGENTS.md`, so Claude Code reads `AGENTS.md` in every case + - the deny list above, merged into `.claude/settings.json` + - a hook that runs `kit context` before every write, so the agent sees the rules without asking + +Any file may also contain a `kit:generate` directive of the repository's own, naming any command. `kit check` fails +when any region differs from what its command produces. + +`kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new +release and running `kit update`, never by editing them in place. diff --git a/.kit/lefthook.yml b/.kit/lefthook.yml new file mode 100644 index 0000000..f1121a3 --- /dev/null +++ b/.kit/lefthook.yml @@ -0,0 +1,19 @@ +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# +# The pre-commit hook runs two commands over the staged files: +# +# - kit-lint runs kit lint: each rule's own linter, declared in the rule's frontmatter, over the files the rule +# governs +# - kit-generate runs kit generate --check, which fails when a generated region is stale +# +# The repository's lefthook.yml includes this file with `extends: [.kit/lefthook.yml]`, and can add commands of its +# own. Lefthook merges commands by name. Run `lefthook install` once per clone. +# +# see: https://lefthook.dev + +pre-commit: + commands: + kit-lint: + run: kit lint {staged_files} + kit-generate: + run: kit generate --check {staged_files} diff --git a/.kit/manifest b/.kit/manifest new file mode 100644 index 0000000..18e97c0 --- /dev/null +++ b/.kit/manifest @@ -0,0 +1,31 @@ +# .kit/manifest - written by kit, not by hand +version unreleased +modules kit ci-github claude lefthook +7b50d15feae7b63b94d845b2fa6e19e3220527c5b0cac69303b7fc19bd7484a8 .claude/CLAUDE.md +deaba46bfba4fa7787fa0bf98832225b1f6926b12cececd921bc935ae5e7da94 .claude/settings.json +05de28f9c629c2ae0364cf268d51f48afa6a80ccb023667ac763370e8c9a733d .github/workflows/kit-ci.yml +919cc90013e2c104fe87c56c5192d327518d3f2adc2eb2da2b0536c6b5c890f4 .gitignore +35f31c081db3393a9d79fac0e6dc707f685a45e8252c5360cf0b44d2a74cabb5 .kit/1st.md +88cfa681c54a2c2f5822d41e25a0c387f4e03a53a10aa612a65f8a2f5e04a0e4 .kit/lefthook.yml +acf52f60f178be28ca2f09bb22f8a982bba0e61eff611ca1a91f1f8dae2ccbce .kit/markdownlint.jsonc +09a3355a208c31d0ff7684c6c42d423305aed85315166219fbe12584750960d3 .kit/rules/bash.md +db3c9f89c38d8890530f8276323cd0e87306876fc7c490004a3f4d10aad911c2 .kit/rules/gitignore.md +9c2ec9138933576774ab176d58e69bfb672eba33f0cf55b11ee8a6822b9739f5 .kit/rules/json.md +63288173fb2da9e215e86d0339cb41a1534ddd9f4bbbb1b94203f9c782d4d587 .kit/rules/just.md +a6b792b7ccca4bd378fcfcef2cd719e1f5c32bcc70539f8c59fa719c46678d09 .kit/rules/markdown.md +651d22705944023b1e043448c2b61204379985f4c45f76cd20546e839842819f .kit/rules/workflows.md +ea7f66ea35c18a8fbc12d601bcc59d5906d3db51977715bb04535b7750e8cc1b .kit/rules/yaml.md +b0fa97914c1194769f5e6e899d5a4fefc71336f14ed23d94e469ababa149f7d2 .kit/settings.schema.json +fe662eb346060b6810f00e447bc982425fed30ee4b43b038a42dd5599a1fe849 .kit/templates/bash/module +940c173655e2158eca3f7e0aedc62f64f94ecc7910c9eb806f9bb654ae7353ae .kit/templates/bash/script +3dc3428c169ffe8d9556e3d544d9ef94b87856deb64480842ab828e8ae467327 .kit/templates/gitignore/file +2d4b394ac3507c23d691be5012af7514417b3622419175df9d77b802f651d095 .kit/templates/gitignore/region +0cf8ab5d48fbc64df6c468fba13b34ab5b6959b1806078888fc7d8d35e043b04 .kit/templates/just/module +3a62d89541cec4c0b08cce135ff9f1d0755688c1adcc4cdd75afd0402bcf13c8 .kit/templates/just/root +82da8fdcf6e46d6a355562a120fe310b808cdaf6635b3d85d5d17a472872cfdb .kit/templates/markdown/agents +e98a16d7ded9d7519f99e83a21c5bc1ec8cf3b6b9cb3c704310cc7224cf24251 .kit/templates/markdown/document +86d1e04b3bd5297b047f9cd1ab088519201ab836b434cb370477f4ee8cf34747 .kit/templates/markdown/frontmatter +4be51fb91d50535d4e898bea86955567653e6d5f4c81970ca981f11474c13b55 .kit/templates/markdown/notice +1892249ef31e1842e99c5316edd4ec500bf52046dc082352bb78f863daef791a .kit/templates/workflows/workflow +f258e584de3af1288947bdcccc10da7efdd3580ec59805f21709e2147d4a5f00 .kit/templates/yaml/document +dddbf2a2466df80288c312eb39bfb1454cd5c0bcb65e4b2975ddcb3d39d5a9cf AGENTS.md diff --git a/.kit/markdownlint.jsonc b/.kit/markdownlint.jsonc new file mode 100644 index 0000000..7d880aa --- /dev/null +++ b/.kit/markdownlint.jsonc @@ -0,0 +1,20 @@ +// .kit/markdownlint.jsonc - the markdownlint configuration the markdown rule's linter reads +// ---------------------------------------------------------------------------------------------------------------------- +// Delivered by kit, not edited by hand. A change is a new release, then kit update. The markdown rule, .kit/rules/markdown.md, +// is the convention; each setting here is one of its lines the linter can hold. + +{ + // MD003 heading-style: ATX headings, the rule's §Layout 5. + "MD003": { "style": "atx" }, + + // MD013 line-length: prose wraps at 118, 120 being the ceiling; code and tables do not wrap (§Layout 1 and 3). + "MD013": { "line_length": 120, "code_blocks": false, "tables": false }, + + // MD041 first-line-heading: a delivered file opens with its notice, AGENTS.md with the sentence a harness reads + // first (§Layout 5). + "MD041": false, + + // MD060 table-column-style: tables use " | " with spaces around each pipe (§Layout 7); the check is stricter + // than the rule and is off. + "MD060": false +} diff --git a/.kit/rules/bash.md b/.kit/rules/bash.md new file mode 100644 index 0000000..a45543a --- /dev/null +++ b/.kit/rules/bash.md @@ -0,0 +1,66 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.sh" + - "**/*.bash" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" +lint: shellcheck --shell=bash --external-sources +templates: + - variant: script + executable: true + - variant: module +--- +# Bash + +Applies to: + +- `*.sh` and `*.bash` files +- recipe bodies under `#!/usr/bin/env bash` +- `run:` blocks in workflows + +## Shape + +1. An executed script opens with these lines, in order: + 1. `#!/usr/bin/env bash` + 2. a `# - ` banner under 120 columns + 3. a line of dashes to column 120 + 4. a blank line + 5. an optional comment paragraph + 6. `set -o errexit -o nounset -o pipefail` +2. A sourced module opens with `# shellcheck shell=bash`, then the same banner. It sets no shell options, because + they would change the caller's options. +3. The file ends with the same line of dashes. + +## Style + +1. `then`, `do`, and `else` go on their own line, never after a semicolon. +2. Spell `set` options out: `set -o errexit`, never `set -e`. +3. Use a tool's long options where every platform accepts them: `--raw-output`, `--field`, `--quiet`. Where BSD + and GNU differ, use the short form, which both accept: `sed -n`, `head -n 1`, `mkdir -p`, `rm -f`, `cut -d`, + `cp -R`, `find -type`, `read -r`, and `git -C`. +4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. +5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. +6. A `case` label has both parentheses: `(vanilla)`. The last group has no `;;`. +7. Quote every expansion outside `[[ ]]` and `(( ))`. +8. Name a function in a sourced module `:`, where `` is the file's basename. The + loader, `1st.sh`, is the exception. Its verbs have bare names (`use`, `info`, `step`, `ok`, `section`, `warn`, + `error`, and `summary`), and its helpers use the `out:` and `guard:` prefixes. +9. Name a function private to one executed script in bare kebab-case. + +## Behavior + +1. An error message is one line. It names the remedy, if any. The same condition gives the same message everywhere. +2. A helper that refuses returns non-zero and lets the caller end the script. It does not call `exit` itself. +3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. +4. Diagnostics go to stderr. Stdout is for data a caller can pipe. +5. A command that changes a remote, a branch, or a worktree is guarded by a check that names what it is about to + do. If the change removes work, as a sweep of worktrees or branches does, the command also has `--dry-run`. +6. Read the default branch from `origin/HEAD`, with `main` as the fallback. Never hard-code it. +7. shellcheck passes with no directive. Where a directive is needed, its reason is on the same line. + +## Comments + +1. A comment states what the code does and any non-obvious reason for it. It contains no history and does not + defend an obvious choice. +2. A pointer is `# see: `, on its own line after the comment it supports. diff --git a/.kit/rules/gitignore.md b/.kit/rules/gitignore.md new file mode 100644 index 0000000..505efbe --- /dev/null +++ b/.kit/rules/gitignore.md @@ -0,0 +1,34 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - ".gitignore" + - "**/.gitignore" +templates: + - variant: file + - variant: region + path: .gitignore +--- +# Ignore lists + +Applies to `.gitignore` at any depth. + +## Shape + +1. A file the repository writes opens with `# .gitignore - untracked files and folders`, a line of dashes to + column 120, and a blank line. A file the kit created that contains only the kit's region belongs to the kit + until the repository adds to it. +2. Group the entries. Each group opens with `# ; see: ` and ends with a blank line. +3. The file ends with a line of dashes. + +## Entries + +1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the + user's global ignore list, not here. +2. Put an entry in the deepest directory where it applies. The root `.gitignore` contains only patterns that apply + across the whole repository. +3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. +4. Ignore `/worktrees/` at the root. The workflow checks out a branch for each issue there. + +## Adding an entry + +Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/.kit/rules/json.md b/.kit/rules/json.md new file mode 100644 index 0000000..c6430da --- /dev/null +++ b/.kit/rules/json.md @@ -0,0 +1,27 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.json" + - "**/*.jsonc" +--- +# JSON + +Applies to `*.json` and `*.jsonc`. + +## Shape + +1. Indent with two spaces. Keep keys in a stable order: as the consuming tool documents, else alphabetical. +2. End with one trailing newline. No trailing commas in `.json`. +3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where it + reads plain JSON, explain a setting in the document that describes the file. + +## Settings + +1. A disabled linter rule has a comment that names the rule and the convention it conflicts with. +2. In a merged settings file, do not edit an entry the kit wrote. Change it through a release and `kit update`. + The repository may edit everything else in the file. + +## Comments + +1. A comment states what the entry does and any non-obvious reason for it. It contains no history. +2. A pointer is `// see: `. diff --git a/.kit/rules/just.md b/.kit/rules/just.md new file mode 100644 index 0000000..0a74945 --- /dev/null +++ b/.kit/rules/just.md @@ -0,0 +1,82 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "justfile" + - "**/justfile" + - "**/*.just" +content: + - "^#!.*/(env +)?just( |$)" +templates: + - variant: root + path: justfile + - variant: module +--- +# Just + +Applies to `justfile` and `*.just`. A `[script]` recipe body is bash, so the bash rule also applies to it. + +## Shape + +1. Every justfile opens with `# - see: https://just.systems/man/en/`, a line of dashes to column 120, + and a description of what the file is and who imports it. The root justfile has the + `#!/usr/bin/env just --justfile` shebang above those lines. A module does not. +2. The settings block comes next: + - `set dotenv-load := true` + - `set fallback := true`, only in a module, never in the root, because the root has no parent + - `set positional-arguments := true` + - `set script-interpreter := ['bash']`, with the interpreter named bare so the pinned bash is used + + `set export := true` comes after the default-and-help pair, so that the pair is identical in every justfile. +3. The root justfile puts the mise-pinned tools first on `PATH`, once, at parse time, from `mise bin-paths`. + Every recipe then calls its tools by bare name, and the pinned tool runs instead of any other copy, both + locally and on a CI runner: + + ````just + export PATH := ``` + mise bin-paths 2>/dev/null | while IFS= read -r d + do + case ":${PATH}:" in (*":${d}:"*) ;; (*) printf '%s:' "${d}" ;; esac + done + ``` + env('PATH') + ```` + +4. Every justfile has the default-and-help pair, with the file's own path in an exported variable: + + ```just + # This file's own path, assigned at parse time so the body below needs no interpolation. + export _justfile := source_file() + + # Default recipe: list this justfile's recipes. + _: help + + # List recipes in this justfile. + [no-exit-message] + @help *options: + just --justfile "${_justfile}" --list + ``` + +5. In a module the root imports with `mod ""`, every recipe has `[no-cd]`. The recipe body then + runs from the invoking root, not from the module's directory. +6. The file ends with a line of dashes. + +## Recipes + +1. Every recipe except the default-and-help pair has `[doc(" ")]`. + Related recipes share an emoji. +2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. The comment states what + the recipe does, when to run it, and what it produces. +3. Write a non-trivial body as a `[script]` recipe, never a shebang recipe. Its first line is + `set -o errexit -o nounset -o pipefail`. Call a tool in it by bare name, never through `mise exec`. +4. A body shows what runs. Move logic longer than a dozen lines into a bash script that the body calls. +5. Use no `{{...}}` interpolation anywhere, in a body or a comment. Pass a value that `just` computes to a body as + an exported variable: `export := `. `set export := true` exports every parameter. A body + refers to values only as `${name}`. +6. Indent a body with two spaces. Never use a tab. +7. Spell out recipe and module names. Never abbreviate them. A second `mod` at the same target is an alias. +8. Comments wrap at 118 columns. Bodies do not wrap. + +## Invocation + +1. Call a module's recipe with spaces: `just tools check`. Never write `just tools/check`, and never write + `tools::check` outside a dependency list. +2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/.kit/rules/markdown.md b/.kit/rules/markdown.md new file mode 100644 index 0000000..aa3f6a3 --- /dev/null +++ b/.kit/rules/markdown.md @@ -0,0 +1,60 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.md" +lint: markdownlint-cli2 --config .kit/markdownlint.jsonc +templates: + - variant: agents + path: AGENTS.md + - variant: document + - variant: notice + - variant: frontmatter +--- +# Markdown + +Applies to `*.md`. + +## Prose + +1. Write plain declarative sentences, with the thing under discussion as the subject. +2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". +3. Use a list for a list, a procedure, or a workflow, so it scans. Use a table where the content has columns. +4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. +5. Do not end a paragraph on a slogan. Do not restate what the code or config already states. +6. Use the serial comma in every list of three or more. +7. Expand an uncommon acronym the first time it appears. +8. A pointer is `see: `, where `` is a URL, a repository-relative path, or `issue #N`. Put it inline + when it keeps the sentence on one line. Otherwise put it on its own line at the end of the paragraph. + +## Snippets + +1. Write a value the reader supplies as a shell variable that the prose names, such as `"${signing_key}"`, never + as an angle-bracket placeholder. The block then works when pasted after one `export`. +2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. + +## Layout + +1. Wrap prose at 118 columns, with 120 as the ceiling. Inside a list item, pull the right margin in by 4 + columns per nesting level, to a limit of 12. +2. Balance the wrap: no line, especially the last, is a short stub. +3. Do not wrap fenced code, tables, HTML comments, or a line that contains only a link. +4. Keep a list tight or loose as it was written. Reflowing never changes one into the other. +5. Use ATX headings (`#`). Use one `#` heading per file, at the top. `AGENTS.md` is the exception: it opens with + the sentence a harness reads first. +6. A fenced code block declares a language and has a blank line before and after it. +7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. + +## Links + +1. Use inline links with descriptive text, never a bare URL in angle brackets. +2. Use the same link text every time the same URL appears in a file. +3. When a label and the link text would be the same, make the label the link. +4. A relative link resolves from the file that contains it, and it must resolve. + +## Files + +1. Name an index file `1st.md`. It lists its siblings and states what each is for. +2. The first paragraph of a file states what its subject is, before explaining how to use it. +3. A README states what the repository is. It has no Status section and no paragraph on what the repository + will do or does not yet do. +4. A delivered file does not name any repository as the source of a rule. diff --git a/.kit/rules/workflows.md b/.kit/rules/workflows.md new file mode 100644 index 0000000..3a9400b --- /dev/null +++ b/.kit/rules/workflows.md @@ -0,0 +1,34 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" +lint: actionlint +templates: + - variant: workflow +--- +# Workflows + +Applies to GitHub Actions workflows under `.github/workflows/`. The YAML rule also applies to them. + +## Shape + +1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a + reader would not guess from the keys. +2. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. +3. Use one job per scope and one step per policy. A job's key is the check name a ruleset requires, so renaming + the job breaks the requirement. +4. Declare `permissions` at the top, granting the least the jobs need. + +## Steps + +1. Every `uses:` pins a full commit SHA, with the version as a trailing comment: `@ # v1.2.3`. +2. Every step has a `name`: the policy it enforces, in kebab-case. +3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. +4. A refusal prints `::error::` naming the remedy, and returns non-zero. If a reader will act on the + refusal, it also writes `### ❌ ` and the remedy to `${GITHUB_STEP_SUMMARY}`. +5. A step whose result a reader looks for writes one `✅` line to the summary. +6. A later step that must run after an earlier refusal has `if: ${{ !cancelled() }}`. +7. A tool gets on `PATH` because the repository pins it and a step installs it, never through `curl` in a step. + The one exception is the kit itself. The delivered job downloads the release that `.kit/manifest` names, + because nothing else can install the kit first. diff --git a/.kit/rules/yaml.md b/.kit/rules/yaml.md new file mode 100644 index 0000000..01f2215 --- /dev/null +++ b/.kit/rules/yaml.md @@ -0,0 +1,22 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.yml" + - "**/*.yaml" +templates: + - variant: document +--- +# YAML + +Applies to `*.yml` and `*.yaml`. A GitHub Actions workflow also follows the workflows rule. + +## Shape + +1. The file opens with a comment: what it configures, and the one thing a reader would not guess from the keys. +2. Indent with two spaces. No tabs. No trailing whitespace. +3. Quote a scalar that contains a colon followed by a space, or that starts with a reserved character. + +## Comments + +1. A comment states the non-obvious reason. It contains no history. +2. A pointer is `# see: `. diff --git a/.kit/settings.schema.json b/.kit/settings.schema.json new file mode 100644 index 0000000..906a759 --- /dev/null +++ b/.kit/settings.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/empowerite/kit/.kit/settings.schema.json", + "title": ".kit/settings.yml", + "description": "The repository's settings. The kit reads this file and never writes it. This schema is delivered by kit, not edited by hand. Each key is described in https://github.com/empowerite/kit/blob/main/docs/modules.md. To have an editor validate the file against this schema, add the modeline: # yaml-language-server: $schema=settings.schema.json", + "type": "object", + "additionalProperties": false, + "properties": { + "forge": { + "type": "string", + "enum": [ + "github", + "gitlab" + ], + "description": "The forge (the service that hosts the repository) the workflow commands use: github or gitlab. When absent, it is detected from origin's host, github.com or gitlab.com. A self-hosted forge must be set here." + }, + "gate": { + "type": "string", + "minLength": 1, + "description": "The command kit gate runs. When absent, kit gate detects a root justfile ci recipe, a Makefile ci target, or a package.json ci script." + }, + "require_signing": { + "type": "boolean", + "description": "When true, kit issue fails before writing anything if git has no signing key configured." + }, + "answers": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z_]+$": { + "type": "string", + "description": "The value a module's templates use for this key, written @key@ in a template." + } + } + }, + "trackers": { + "type": "array", + "description": "The issue trackers the workflow uses in addition to the forge's own issues. The forge's tracker handles the # prefix unless an entry here claims it. A reference names its tracker by its prefix: #7, AB#4521.", + "items": { + "type": "object", + "required": [ + "prefix", + "provider" + ], + "additionalProperties": false, + "properties": { + "prefix": { + "type": "string", + "pattern": "^[A-Za-z]*#?$", + "description": "The text a reference to this tracker starts with, before the number: AB# for Azure DevOps work items." + }, + "provider": { + "type": "string", + "enum": [ + "forge", + "ado" + ], + "description": "The tracker that handles the prefix: forge for the forge's own issues, ado for Azure DevOps work items." + }, + "organization": { + "type": "string", + "description": "ado: the organization's URL, https://dev.azure.com/." + }, + "project": { + "type": "string", + "description": "ado: the project a new work item is created in." + }, + "type": { + "type": "string", + "description": "ado: the type of a new work item. Default: Task." + } + } + } + }, + "toolchains": { + "type": "array", + "description": "The toolchains that put the gate's tools on PATH, in order. When absent, they are detected from mise.toml, Brewfile, and toolchain.Dockerfile.", + "items": { + "type": "object", + "required": [ + "provider" + ], + "additionalProperties": false, + "properties": { + "provider": { + "type": "string", + "enum": [ + "mise", + "brew", + "docker" + ], + "description": "The tool manager: mise, brew, or docker." + }, + "file": { + "type": "string", + "description": "brew and docker: the file the tool manager reads. Default: Brewfile or toolchain.Dockerfile." + }, + "image": { + "type": "string", + "description": "docker: the name of the built image. Default: kit-toolchain." + } + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "description": "The patterns that the branch name, commit subjects, and pull request title must match. Each is an extended regular expression, limited to the subset RE2 also supports. An absent key uses the kit's default. An empty pattern turns that rule off.", + "properties": { + "branch": { + "type": "string", + "description": "The pattern for the branch name. Default: ^[a-z0-9]+-[a-z0-9][a-z0-9-]*$, the shape kit issue gives a branch." + }, + "commit_subject": { + "type": "string", + "description": "The pattern for the subject of every commit since the branch forked. Default: ^([A-Z]+)?#[0-9]+ [^ ], a subject that starts with its issue's reference." + }, + "change_title": { + "type": "string", + "description": "The pattern for the open pull request's title. Default: ^[^[:cntrl:]]{1,120}$." + } + } + }, + "policy_engine": { + "type": "string", + "enum": [ + "patterns", + "opa" + ], + "description": "The policy engine: patterns, matched in bash, or opa, which evaluates the Rego under .kit/policy. When absent, opa is used if a .rego file is there, and patterns otherwise." + } + } +} diff --git a/.kit/settings.yml b/.kit/settings.yml new file mode 100644 index 0000000..fc21734 --- /dev/null +++ b/.kit/settings.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=settings.schema.json +require_signing: true +# The commit-subject rule is off for the history of #1, the first release, whose early commits predate it. Remove +# this comment and the two lines below once that pull request merges, and every later branch is held to the kit's default. +policy: + commit_subject: '' diff --git a/.kit/templates/bash/module b/.kit/templates/bash/module new file mode 100644 index 0000000..b4c4b63 --- /dev/null +++ b/.kit/templates/bash/module @@ -0,0 +1,5 @@ +# shellcheck shell=bash +# «name» - «purpose» +# ---------------------------------------------------------------------------------------------------------------------- +«body» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/templates/bash/script b/.kit/templates/bash/script new file mode 100644 index 0000000..ddfdf99 --- /dev/null +++ b/.kit/templates/bash/script @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# «name» - «purpose» +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set -o errexit -o nounset -o pipefail +«body» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/templates/gitignore/file b/.kit/templates/gitignore/file new file mode 100644 index 0000000..904cc2f --- /dev/null +++ b/.kit/templates/gitignore/file @@ -0,0 +1,4 @@ +# .gitignore - untracked files and folders +# ---------------------------------------------------------------------------------------------------------------------- +«entries» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/templates/gitignore/region b/.kit/templates/gitignore/region new file mode 100644 index 0000000..083cc3e --- /dev/null +++ b/.kit/templates/gitignore/region @@ -0,0 +1,3 @@ +# kit:generate .gitignore +«entries» +# kit:end .gitignore diff --git a/.kit/templates/just/module b/.kit/templates/just/module new file mode 100644 index 0000000..5c3a322 --- /dev/null +++ b/.kit/templates/just/module @@ -0,0 +1,22 @@ +# «name» - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set dotenv-load := true +set fallback := true +«settings» +set positional-arguments := true +«settings» +set script-interpreter := ['bash'] +«settings» +# This file's own path, assigned at parse time so the body below needs no interpolation. +export «stem»_justfile := source_file() + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "${«stem»_justfile}" --list +«recipes» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/templates/just/root b/.kit/templates/just/root new file mode 100644 index 0000000..8e63eec --- /dev/null +++ b/.kit/templates/just/root @@ -0,0 +1,22 @@ +#!/usr/bin/env just --justfile +# justfile - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set dotenv-load := true +«settings» +set positional-arguments := true +«settings» +set script-interpreter := ['bash'] +«settings» +# This file's own path, assigned at parse time so the body below needs no interpolation. +export root_justfile := source_file() + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "${root_justfile}" --list +«recipes» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/.kit/templates/markdown/agents b/.kit/templates/markdown/agents new file mode 100644 index 0000000..df26dab --- /dev/null +++ b/.kit/templates/markdown/agents @@ -0,0 +1 @@ +«body» diff --git a/.kit/templates/markdown/document b/.kit/templates/markdown/document new file mode 100644 index 0000000..7a12cfe --- /dev/null +++ b/.kit/templates/markdown/document @@ -0,0 +1,2 @@ +# «title» +«body» diff --git a/.kit/templates/markdown/frontmatter b/.kit/templates/markdown/frontmatter new file mode 100644 index 0000000..5dea690 --- /dev/null +++ b/.kit/templates/markdown/frontmatter @@ -0,0 +1,4 @@ +--- +«frontmatter» +--- +«body» diff --git a/.kit/templates/markdown/notice b/.kit/templates/markdown/notice new file mode 100644 index 0000000..aa92f12 --- /dev/null +++ b/.kit/templates/markdown/notice @@ -0,0 +1,3 @@ + +# «title» +«body» diff --git a/.kit/templates/workflows/workflow b/.kit/templates/workflows/workflow new file mode 100644 index 0000000..0eefc0a --- /dev/null +++ b/.kit/templates/workflows/workflow @@ -0,0 +1,10 @@ +# «purpose» +«about» +name: «workflow» +«body» +on: +«triggers» +permissions: +«permissions» +jobs: +«jobs» diff --git a/.kit/templates/yaml/document b/.kit/templates/yaml/document new file mode 100644 index 0000000..9b74360 --- /dev/null +++ b/.kit/templates/yaml/document @@ -0,0 +1,2 @@ +# «purpose» +«body» diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..c676c99 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,10 @@ +// .markdownlint-cli2.jsonc - markdownlint-cli2 options: what the linter scopes in and out +// +// Rule configuration lives in .markdownlint.jsonc; this file defines scope. The repository's .gitignore files are +// the source of truth for exclusions, so worktrees/ is never linted, and the glob form scopes to ignore files +// inside the tree being linted, so a per-issue worktree lints identically to the repository root checkout. +// +// see: https://github.com/DavidAnson/markdownlint-cli2#markdownlint-cli2jsonc +{ + "gitignore": "**/.gitignore" +} diff --git a/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml b/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml new file mode 100644 index 0000000..0e702f7 --- /dev/null +++ b/.mise/locks/npm-ajv-cli/5.0.0/aube-lock.yaml @@ -0,0 +1,202 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ajv-cli: + specifier: 5.0.0 + version: 5.0.0 + +packages: + + ajv-cli@5.0.0: + resolution: {integrity: sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==} + hasBin: true + peerDependencies: + ts-node: '>=9.0.0' + peerDependenciesMeta: + ts-node: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-patch@2.2.1: + resolution: {integrity: sha512-4j5uBaTnsYAV5ebkidvxiLUYOwjQ+JSFljeqfTxCrH9bDmlCQaOJFS84oDJ2rAXZq2yskmk3ORfoP9DCwqFNig==} + engines: {node: '>= 0.4.0'} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + + json-schema-migrate@2.0.0: + resolution: {integrity: sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + ajv-cli@5.0.0: + dependencies: + ajv: 8.20.0 + fast-json-patch: 2.2.1 + glob: 7.2.3 + js-yaml: 3.15.2 + json-schema-migrate: 2.0.0 + json5: 2.2.3 + minimist: 1.2.8 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + balanced-match@1.0.2: {} + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + concat-map@0.0.1: {} + + esprima@4.0.1: {} + + fast-deep-equal@2.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-patch@2.2.1: + dependencies: + fast-deep-equal: 2.0.1 + + fast-uri@3.1.9: {} + + fs.realpath@1.0.0: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + json-schema-migrate@2.0.0: + dependencies: + ajv: 8.20.0 + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimist@1.2.8: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-is-absolute@1.0.1: {} + + require-from-string@2.0.2: {} + + sprintf-js@1.0.3: {} + + wrappy@1.0.2: {} diff --git a/.mise/locks/npm-ajv-cli/5.0.0/package.json b/.mise/locks/npm-ajv-cli/5.0.0/package.json new file mode 100644 index 0000000..3203299 --- /dev/null +++ b/.mise/locks/npm-ajv-cli/5.0.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "ajv-cli": "5.0.0" + } +} diff --git a/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml b/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml new file mode 100644 index 0000000..4a6b0cf --- /dev/null +++ b/.mise/locks/npm-markdownlint-cli2/0.24.0/aube-lock.yaml @@ -0,0 +1,735 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + markdownlint-cli2: + specifier: 0.24.0 + version: 0.24.0 + +packages: + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + argparse@3.0.3: + resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@16.2.4: + resolution: {integrity: sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==} + engines: {node: '>=20'} + + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} + engines: {node: '>= 4'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + + markdown-it@15.0.3: + resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==} + hasBin: true + + markdownlint-cli2-formatter-default@0.0.6: + resolution: {integrity: sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==} + peerDependencies: + markdownlint-cli2: '>=0.0.4' + + markdownlint-cli2@0.24.0: + resolution: {integrity: sha512-c5hU2MDoTlS230MLzdNlkIyxImXq5acxGKz4nYz4aDhH/2BgFtpsA7iK9/OoTHMgZ4FuR0wedTQA31K6Lp9I/Q==} + engines: {node: '>=22'} + hasBin: true + + markdownlint@0.42.0: + resolution: {integrity: sha512-kzsCV/rPWRFn3jUhcB919YF4Qgeh0g9tcogoXFadGSo6Zxj6662YOqWXv0jDn8nv5KCCxDfMKJZv6N4HkNkvTg==} + engines: {node: '>=22'} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + re2js@2.8.6: + resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==} + engines: {node: '>=18.0.0'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + + unicorn-magic@0.4.1: + resolution: {integrity: sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==} + engines: {node: '>=20'} + +snapshots: + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@sindresorhus/merge-streams@4.0.0': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/katex@0.16.8': {} + + '@types/ms@2.1.0': {} + + '@types/unist@2.0.11': {} + + ansi-regex@6.4.0: {} + + argparse@2.0.1: {} + + argparse@3.0.3: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + commander@8.3.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + entities@8.1.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + get-east-asian-width@1.7.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@16.2.4: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.13 + is-path-inside: 4.0.0 + micromatch: 4.0.8 + slash: 5.1.0 + unicorn-magic: 0.4.1 + + ignore@7.0.13: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + js-yaml@5.4.3: + dependencies: + argparse: 2.0.1 + + jsonc-parser@3.3.1: {} + + jsonpointer@5.0.1: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + + markdown-it@15.0.3: + dependencies: + argparse: 3.0.3 + entities: 8.1.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + + markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.24.0): + dependencies: + markdownlint-cli2: 0.24.0 + + markdownlint-cli2@0.24.0: + dependencies: + globby: 16.2.4 + js-yaml: 5.4.3 + jsonc-parser: 3.3.1 + jsonpointer: 5.0.1 + markdown-it: 15.0.3 + markdownlint: 0.42.0 + markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.24.0) + micromatch: 4.0.8 + smol-toml: 1.9.1 + transitivePeerDependencies: + - supports-color + + markdownlint@0.42.0: + dependencies: + micromark: 4.0.3 + micromark-core-commonmark: 2.0.4 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-math: 3.1.0 + micromark-util-types: 2.0.3 + re2js: 2.8.6 + string-width: 8.3.0 + transitivePeerDependencies: + - supports-color + + mdurl@2.1.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + ms@2.1.3: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picomatch@2.3.2: {} + + punycode.js@2.3.1: {} + + queue-microtask@1.2.3: {} + + re2js@2.8.6: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + slash@5.1.0: {} + + smol-toml@1.9.1: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + uc.micro@3.0.0: {} + + unicorn-magic@0.4.1: {} diff --git a/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json b/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json new file mode 100644 index 0000000..52130b8 --- /dev/null +++ b/.mise/locks/npm-markdownlint-cli2/0.24.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "markdownlint-cli2": "0.24.0" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..772c286 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md + + +Read .kit/1st.md at the start of every session. It defines how any agent uses empowerite/kit to keep this +repository trustworthy. + + +This repository is the kit's own source, and uses it. + +## This repository's own rules + +- The release is `bin/` and `modules/`. Everything in it is written for a consumer: no file names a consumer, a + person, or another repository as the source of a rule. +- Releases and pre-releases: [README.md](README.md) § Release. +- `.kit/`, `.github/workflows/kit-ci.yml`, `.claude/settings.json`, and the regions in `AGENTS.md`, `.gitignore`, and + `.claude/CLAUDE.md` are this repository's installed copies of its own kit, written by `just refresh` from `modules/`; + see [README.md](README.md) § Maintaining. Edit the source; the pre-commit hook refreshes the copies into the same + commit, and a desk without the hook runs `just refresh` and commits both. +- `just ci` is the gate; the justfile says what it runs. diff --git a/README.md b/README.md index cda6989..c6e5fa8 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,242 @@ -# kit +# empowerite/kit: **k**eep **i**t **t**rustworthy -kit is the agentic guardrails a repository takes as a versioned mixin: the policy text an agent reads first, the -rules that bind each kind of file, the guard that keeps `main` as the human's desk, and the one command no harness -provides, the issue-to-worktree on-ramp. A tag on `main` publishes a release. A consumer pins it in `mise.toml`, -installs it with `kit install`, and keeps rules of its own beside it. What the first release delivers, and what the -kit is not, is [#1](https://github.com/empowerite/kit/issues/1). +kit is a versioned set of rules for AI coding agents, plus the checks that enforce them. A repository installs a +release, pins it, and updates it like any other tool. + +Agents learn from everything people have written, including the shortcuts: work called done that was not, a skipped +test reported as passing, a rebase that overwrote a colleague's branch. kit puts the rules where agents read them and +checks the work in CI, so the result does not depend on an agent's word. + +What kit checks: + +- `kit ci` refuses a pull request when: + - it is not linked to an open issue, + - its branch name, commit subjects, or title break the repository's naming policy, or + - the repository's gate did not run. + + It runs in the delivered GitHub Actions job or in any other pipeline. +- `kit check` fails when a delivered file differs from the release, or a generated region differs from the output + of its command. +- `kit lint` runs each rule's linter over the files the rule covers, before a commit or at any time. +- A deny list blocks the raw git commands the rules forbid, such as `git rebase`. + +What else kit provides: + +- `.kit/1st.md`, the file every agent reads first, and one rule file per kind of file. +- `kit issue`, which turns an issue into a worktree, a linked branch, and a draft pull request. +- `kit toolchain`, which installs, lists, and updates the gate's tools (mise, Homebrew, or a container image) and + runs the gate with them. +- `kit status`, which shows every open pull request and the result of its checks. +- A rule that agents never change the desk: the root checkout on your machine, on whatever branch it currently has. + +A repository keeps its own rules next to the delivered ones. The design is in +[#1](https://github.com/empowerite/kit/issues/1) and [#4](https://github.com/empowerite/kit/issues/4). + +## Why a release, and not a copy + +A copied file drifts. A release does not: + +- It is pinned, so a repository knows which version of the rules applies. +- It is updated by a pull request that bumps the version, so every repository gets the change the same way. +- It is checked: `kit check` fails the build when someone edits a delivered file in place. + +A repository's own rules live in its own files, and updates leave them alone. + +## Install + +A release is a tarball containing `bin/`, `modules/`, and a `version` file. Install it the same way the +repository installs its other tools. + +With mise, pin it in `mise.toml`: + +```toml +[tools] +"github:empowerite/kit" = { version = "0.1.0", version_prefix = "release/" } +``` + +With Homebrew: + +```sh +brew install empowerite/tap/kit +``` + +By hand, with `gh`. This downloads the latest release, never a pre-release. For a specific version, run +`gh release download "release/${version}" ...` instead. Unpack it and put its `bin/` on `PATH`: + +```sh +gh release download --repo empowerite/kit --pattern 'kit-*.tar.gz' --output kit.tar.gz +mkdir kit && tar --extract --gzip --file kit.tar.gz --directory kit --strip-components 1 +export PATH="${PWD}/kit/bin:${PATH}" +``` + +Requirements: + +- bash 4.4 or later. macOS ships bash 3.2; run `brew install bash`. +- git and `jq`. +- For `kit issue`, `kit status`, `kit tidy`, and `kit linked`: the command-line tool of the forge, the service that + hosts the repository. That is `gh` for GitHub or `glab` for GitLab. + +With mise, an exact version pin installs as soon as a release exists. A range pin waits one day after a release +unless `[settings]` contains `minimum_release_age_excludes = ["github:empowerite/kit"]`. + +## Take the kit + +1. Run `kit install` with the modules you want. The `kit` module (the entry file and the rules) is always + installed. The optional modules are: + + | Module | Delivers | + | --- | --- | + | `ci-github` | the GitHub Actions workflow that runs `kit ci` | + | `lefthook` | a pre-commit hook that runs `kit lint` | + | `claude` | the Claude Code adapter | + | `policy-opa` | the naming policy as Open Policy Agent rules | + + `kit install ci-github lefthook claude` takes three of them. `kit module add` adds one later. + + `install` also writes a line into `AGENTS.md` telling every agent to read `.kit/1st.md`, and adds `/worktrees/` + to `.gitignore`. Each sits in a region between a `kit:generate` line and a `kit:end` line. The rest of each file + is yours, and you can move the region anywhere in it. `kit generate` rewrites a region from its command, and + `kit check` fails when a region is out of date. Your own files can contain regions too; see + [docs/generate.md](docs/generate.md). +2. Commit what `install` wrote. +3. Give the repository a gate, the command that CI runs. kit looks for one of these: + - a `ci` recipe in the root justfile + - a `ci` target in a Makefile + - a `ci` script in `package.json` + - any command, set as `gate` in `.kit/settings.yml` + + `kit ci` runs the gate together with `kit check`, `kit linked`, and `kit policy`, and fails when there is no gate. + The gate runs with the tools your toolchain manages; see [docs/toolchains.md](docs/toolchains.md). The naming + policy is described in [docs/policy.md](docs/policy.md). +4. Run the linters before each commit. `kit lint` lints the staged files and needs no hook manager. To run it from + lefthook instead: + - pin `lefthook`, + - add `extends: [.kit/lefthook.yml]` to `lefthook.yml`, and + - run `lefthook install` once per clone. + + With the `lefthook` module installed, `kit check` fails until `lefthook.yml` has that `extends` line. kit never + writes it for you. + +Claude Code reads `AGENTS.md` only when there is no `CLAUDE.md`. The `claude` module adds a region to +`.claude/CLAUDE.md` that imports `AGENTS.md`, so Claude always reads the same instructions as every other agent. The +rest of `.claude/CLAUDE.md` is yours. Without the module, any agent that reads `AGENTS.md` still gets the rules. + +`kit module list`, `kit module add`, and `kit module remove` show and change the installed modules. The module format +is in [docs/modules.md](docs/modules.md). + +### Working with the rules + +- Before an agent changes a file, it runs `kit context "${path}"` to find the rules that apply. The `claude` module + makes this call automatically through a Claude Code hook; see [docs/context.md](docs/context.md). +- `kit new file "${path}"` creates a file from its rule's template, and `kit lint` checks that every file keeps the + shape of a template; see [docs/templates.md](docs/templates.md). + +### Updating + +Bump the pin or reinstall, then run `kit update`: + +- A delivered file you never edited is replaced. +- A delivered file you edited is merged three ways, with the previous release as the base. `kit update` downloads + the previous release from GitHub when it is not on the machine. Where it cannot, set `KIT_BASE` to a copy of it. +- Your own deny list entries and settings keys are kept. + +`kit check` fails, locally and in CI, when a delivered file differs from the pinned release. ## Setup -Every commit is signed, and the branch ruleset refuses one that is not. Once per clone: +The rest of this README is about working on this repository, which is the kit's own source and also uses the kit. + +Tools come from mise. Every commit must be signed; the branch ruleset rejects unsigned commits. If you have no signing +key that GitHub verifies, create and register one: + +- [generating a new GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) +- [adding a GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account) + +Then, once per clone, with the key's id in `signing_key`: ```sh git config gpg.format openpgp -git config user.signingkey +git config user.signingkey "${signing_key}" git config commit.gpgsign true +export MISE_ENV=desk +mise install +lefthook install ``` +Put `export MISE_ENV=desk` in your shell profile. It makes mise read `mise.desk.toml`, the tools only a developer +machine needs, on top of `mise.toml`. The delivered CI job sets `MISE_ENV=ci` the same way, for repositories that have +a `mise.ci.toml`. + ## Getting started -Work here moves by the loop the kit itself delivers. An issue exists. A branch is checked out in a worktree under -`worktrees/`, linked to the issue under Development, with a draft pull request. Commits are signed, and their -subject is `# : `. `gh pr ready` says the work is done, and the pull request body is the report. -A human merges. Nothing reaches `main` any other way. +Work here follows the workflow in [.kit/1st.md](.kit/1st.md): + +1. Start from an issue. A change without an issue has not started. +2. Run `kit issue `. It creates `worktrees/-`, links the branch to the issue under Development, and opens + a draft pull request. It never switches the branch of the checkout you run it from. For an issue in another + tracker, use that tracker's reference, such as `kit issue AB#4521` for an Azure DevOps work item; see + [docs/trackers.md](docs/trackers.md). +3. Work in that worktree. Commits are signed, with the subject `# : `. + - `kit rebase` rebases the branch onto `main` and pushes it. + - `kit status` shows every open pull request and the worktree it lives in. +4. Run the gate, `just ci`. The justfile lists what it runs. +5. Run `gh pr ready` when the work is done. The pull request body is the report. +6. A human merges. Then `kit tidy` removes the worktree and its branch. + +## Maintaining + +| Directory | Contents | +| --- | --- | +| `bin/` | the `kit` command and its verbs | +| `modules/` | everything a release delivers | +| `docs/`, `modules/*/docs/` | the documentation | + +[docs/architecture.md](docs/architecture.md) describes how the pieces fit, with diagrams. +[docs/modules.md](docs/modules.md) describes the module format. + +This repository installs its own kit. The installed copies are written by `bin/kit install`; do not edit them by hand: + +| Installed copy | Written from | +| --- | --- | +| `.kit/1st.md`, `.kit/rules/`, `.kit/settings.schema.json` | `modules/kit/files/repo/.kit/` | +| `.kit/lefthook.yml` | `modules/lefthook/files/repo/.kit/lefthook.yml` | +| `.github/workflows/kit-ci.yml` | `modules/ci-github/templates/repo/.github/workflows/kit-ci.yml`, with `ci_runner` filled in | +| `.claude/CLAUDE.md` | `modules/claude/regions/repo/.claude/CLAUDE.md`, a region; the rest of the file belongs to this repository | +| `.claude/settings.json` | `modules/claude/files/repo/.claude/settings.json`, merged by key; here only the kit's entries | +| `AGENTS.md`, `.gitignore` | `modules/kit/regions/repo/`, regions; the rest of each file belongs to this repository | +| `.kit/manifest` | the release version (`unreleased` here), the active modules, and a digest per delivered path | + +`.kit/settings.yml` belongs to this repository and is not delivered. + +When a commit changes a file under `modules/`, the installed copies must change in the same commit: + +- With the pre-commit hook installed, this happens automatically: the hook runs `just refresh`, which regenerates the + copies from the index and stages them. +- Without the hook, stage your change, run `just refresh`, and commit. + +`bin/kit check`, which `just ci` runs, names any installed copy that does not match its source. Each delivered file +says in its header that kit delivered it. A region's `kit:generate` line serves as its notice, and the table above +serves as the notice for `settings.json`. + +## Release + +- A release is the tag `release/` on any commit on `main`, not only the latest one. +- A pre-release is `release/-`, such as `release/0.1.0-rc.1`, on a commit that is not on `main`. + It is published as a GitHub pre-release. +- The tag is the version. The repository has no version file. The release workflow writes the part after `release/` + into the packed kit and names the release after it. +- Published releases are immutable, so a tag is never reused. + +The release workflow runs two commands: -Until the kit can run itself, those steps are taken by hand. The first thing #1 delivers is the command that takes -them. +1. `kit release verify` rejects a tag that is not `release/`, a release tag on a commit not on `main`, and a + pre-release tag on a commit on `main`. +2. `kit release publish` packs `bin/`, `modules/`, and `version` into `kit-.tar.gz` and publishes it. -## Status +`kit release pack` builds the same tarball locally. `kit release publish` refuses to publish a release anywhere but +the CI runner, so only the workflow publishes releases. A pre-release can be published from a developer machine. -Pre-alpha. There is no code yet, so nothing here builds, tests, or runs. The repository will use its own kit as soon -as the kit exists. +A release is permanent. A pre-release is deleted, tag and all, once the branch it was tagged on is deleted and no +other branch contains its commit. -Repository policy is managed by Terraform; see: empowerite/infrastructure/terraform/projects/kit/main.tf +The branch ruleset is managed in [empowerite/infrastructure](https://github.com/empowerite/infrastructure). diff --git a/bin/1st.sh b/bin/1st.sh new file mode 100644 index 0000000..28cdb5b --- /dev/null +++ b/bin/1st.sh @@ -0,0 +1,130 @@ +# shellcheck shell=bash +# 1st.sh - the kit's bash loader: output verbs, guards, and lazy module loading (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Every verb, bin/kit-.sh, sources this file. bin/kit runs the verb with kit_root exported. This file only +# defines functions and sets no shell options, so the caller's options stay in effect. +# +# - A verb is an executable kit-.sh in this directory. +# - A module is a sourced .sh in this directory. The verb or module that needs it loads it with +# `use `, which sources it at most once. +# +# Every message goes to stderr, so stdout carries only data. Color is off when stderr is not a terminal or NO_COLOR +# is set. Under GitHub Actions, warn and error also print the matching workflow command (::warning::, ::error::). + +kit_bin_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +declare -gr kit_bin_dir +declare -Ag kit_modules=([1st]=1) + +# use … sources bin/.sh, once per module. An unknown module is a bug in the caller: use prints an +# error and returns 2. +use() { + local module + for module in "$@" + do + [[ ${kit_modules[${module}]+x} ]] && continue + if [[ ! -f "${kit_bin_dir}/${module}.sh" ]] + then + error "no such kit bash module: ${module} (looked in ${kit_bin_dir})" + return 2 + fi + kit_modules[${module}]=1 + # shellcheck disable=SC1090 # the path is computed from the module name + source "${kit_bin_dir}/${module}.sh" + done +} + +if [[ -n "${NO_COLOR:-}" ]] +then + declare -gr out_color=0 +elif [[ -t 2 ]] +then + declare -gr out_color=1 +else + declare -gr out_color=0 +fi + +# ere:quote prints the text with every extended-regular-expression metacharacter escaped, so it matches +# literally in a pattern. It is pure bash, so BSD and GNU sed differences in bracket expressions do not matter. +ere:quote() { + local text=$1 out='' c i + for (( i = 0; i < ${#text}; i++ )) + do + c=${text:i:1} + case "${c}" in + ('.' | '[' | ']' | '*' | '^' | '$' | "\\" | '+' | '?' | '(' | ')' | '{' | '}' | '|') out+="\\${c}" ;; + (*) out+=${c} ;; + esac + done + printf '%s\n' "${out}" +} + +# out:sgr prints the escape sequence for one SGR code when color is on, and nothing otherwise. +out:sgr() { + (( out_color )) && printf '\x1b[%sm' "$1" + return 0 +} + +# out:emit … prints one line to stderr. +out:emit() { + local icon=$1 sgr=$2 + shift 2 + printf '%s%s %s%s\n' "$(out:sgr "${sgr}")" "${icon}" "$*" "$(out:sgr 0)" >&2 +} + +info() { out:emit '•' '' "$@"; } +step() { out:emit '→' '36' "$@"; } +ok() { out:emit '✅' '32' "$@"; } +section() { out:emit '' '1' "── $* ──"; } + +warn() { + out:emit '⚠️' '33' "$@" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::warning::$*" >&2 + return 0 +} + +error() { + out:emit '❌' '31' "$@" + [[ -n "${GITHUB_ACTIONS:-}" ]] && echo "::error::$*" >&2 + return 0 +} + +# summary … appends lines to the GitHub Actions step summary when GITHUB_STEP_SUMMARY is set, and does +# nothing otherwise. +summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 + printf '%s\n' "$@" >> "${GITHUB_STEP_SUMMARY}" +} + +# guard:require … checks each predicate in order. At the first one that fails, it prints the remedy and +# returns non-zero. The predicates: +# signing-key git user.signingkey is set +# tool: is on PATH +guard:require() { + local predicate + for predicate in "$@" + do + case "${predicate}" in + (signing-key) + if [[ -z "$(git config user.signingkey 2>/dev/null)" ]] + then + error "run: git config user.signingkey (every commit here is signed, and none is configured)" + return 1 + fi + ;; + (tool:*) + local tool=${predicate#tool:} + if ! command -v "${tool}" >/dev/null 2>&1 + then + error "run: mise install (${tool} is not on PATH)" + return 1 + fi + ;; + (*) + error "guard:require: unknown predicate '${predicate}'" + return 1 + esac + done +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/context.sh b/bin/context.sh new file mode 100644 index 0000000..cea3df8 --- /dev/null +++ b/bin/context.sh @@ -0,0 +1,158 @@ +# shellcheck shell=bash +# context.sh - which rules govern a file (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# context:render … [--json] prints the rule files under .kit/rules/ that govern each path. It also logs each +# request in the repository's context log. A rule's frontmatter declares what it governs: +# +# - `paths:` globs matched against the repository-relative path, where ** matches any depth +# - `content:` patterns matched against the first line of a file that exists, so a bash script with no extension +# is matched by its shebang +# +# The rules are found where they are and copied nowhere, so every harness and every person finds them the same way. +# +# context:claude gives the same answer in Claude Code's hook protocol. It reads the hook input on stdin and prints +# the rules' paths as additional context on stdout. It names the rules but does not quote them, so the agent still +# reads each rule itself, and a hook on Read can record that. +# +# see: docs/context.md + +use yaml +shopt -s extglob + +# context:log-path prints the path of the context log. It is under the repository's common git directory, so every +# worktree shares it. +context:log-path() { + printf '%s/kit/context.log\n' "$(git rev-parse --path-format=absolute --git-common-dir)" +} + +# context:relative prints relative to the repository root. A relative starts from the +# directory the command was typed in, which bin/kit exports as kit_invoked_in before it changes to the root. The +# nearest existing directory in the path is resolved physically (pwd -P), the way git reports the root. So a path +# through a symlinked directory, or to a file not yet created, still comes out relative to the root. +context:relative() { + local path=$1 root dir rest + root=$(git rev-parse --show-toplevel) + [[ "${path}" == /* ]] || path="${kit_invoked_in:-$(pwd -P)}/${path}" + dir=${path%/*} + rest=${path##*/} + while [[ -n "${dir}" && ! -d "${dir}" ]] + do + rest="${dir##*/}/${rest}" + dir=${dir%/*} + done + dir=$(cd "${dir:-/}" && pwd -P) + path="${dir}/${rest}" + printf '%s\n' "${path#"${root}"/}" +} + +# context:glob-matches is true when the repository-relative path matches the glob. ** matches any +# depth. A lone * also matches across directories, as in bash's [[ == ]], so *.md matches at every depth. A glob +# with no wildcard, such as justfile, matches one path. +context:glob-matches() { + local path=$1 glob=$2 + glob=${glob//\*\*\//*(*\/)} + glob=${glob//\*\*/*} + # shellcheck disable=SC2053 # the glob is the point + [[ "${path}" == ${glob} ]] +} + +# context:rules prints each rule file that governs , by a `paths:` glob or a `content:` pattern. +context:rules() { + local path=$1 rule glob pattern first='' + [[ -f "${path}" ]] && first=$(head -n 1 "${path}" 2>/dev/null || true) + for rule in .kit/rules/*.md + do + [[ -f "${rule}" ]] || continue + # Read each glob with read, not word splitting: an unquoted glob would expand against the working tree first. + while IFS= read -r glob + do + [[ -n "${glob}" ]] || continue + if context:glob-matches "${path}" "${glob}" + then + printf '%s\n' "${rule}" + continue 2 + fi + done < <(yaml:list "${rule}" paths) + [[ -n "${first}" ]] || continue + while IFS= read -r pattern + do + [[ -n "${pattern}" ]] || continue + if [[ "${first}" =~ ${pattern} ]] + then + printf '%s\n' "${rule}" + continue 2 + fi + done < <(yaml:list "${rule}" content) + done +} + +# context:log appends the time and to the context log, recording that its rules were requested. +context:log() { + local log + log=$(context:log-path) + mkdir -p "$(dirname "${log}")" + printf '%s\t%s\n' "$(date +%s)" "$1" >> "${log}" +} + +# context:render … [--json] logs each path and prints the rules that govern the paths. Without --json, it +# prints each rule's path once. With --json, it prints one object per path with its rules. +context:render() { + local json=false arg path rule + local -a paths=() + local -A seen=() + for arg in "$@" + do + case "${arg}" in + (--json) json=true ;; + (-*) + error "unknown flag '${arg}' (usage: kit context … [--json] | kit context --claude)" + return 2 + ;; + (*) paths+=("${arg}") + esac + done + if (( ${#paths[@]} == 0 )) + then + error "usage: kit context … [--json] | kit context --claude" + return 2 + fi + cd "$(git rev-parse --show-toplevel)" || return 1 + for path in "${paths[@]}" + do + path=$(context:relative "${path}") + context:log "${path}" + if [[ "${json}" == true ]] + then + context:rules "${path}" | jq --raw-input --slurp --compact-output --arg path "${path}" \ + '{ path: $path, rules: [split("\n")[] | select(. != "")] }' + continue + fi + while IFS= read -r rule + do + [[ -n "${rule}" && -z "${seen[${rule}]:-}" ]] || continue + seen[${rule}]=1 + printf '%s\n' "${rule}" + done < <(context:rules "${path}") + done +} + +# context:claude reads Claude Code's PreToolUse hook input on stdin and logs the file the tool is about to write. +# It prints, as additional context, the paths of the rules that govern that file. It prints nothing when the tool +# input has no file path or no rule governs the file. +context:claude() { + local input path rules text='' + input=$(cat) + path=$(jq --raw-output '.tool_input.file_path // empty' <<<"${input}") + [[ -n "${path}" ]] || return 0 + cd "$(git rev-parse --show-toplevel)" || return 0 + path=$(context:relative "${path}") + context:log "${path}" + rules=$(context:rules "${path}" | paste -s -d ' ' -) + [[ -n "${rules}" ]] || return 0 + text="kit: ${path} is governed by ${rules}; read it before this change." + jq --null-input --compact-output --arg text "${text}" \ + '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $text } }' +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/forge-github.sh b/bin/forge-github.sh new file mode 100644 index 0000000..bedd98e --- /dev/null +++ b/bin/forge-github.sh @@ -0,0 +1,206 @@ +# shellcheck shell=bash +# forge-github.sh - the GitHub forge provider, through the gh CLI (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# shellcheck disable=SC2016 # jq filters and GraphQL queries name their variables with $, in single quotes on purpose +# +# bin/forge.sh loads this provider when the repository's forge is github. It implements the calls listed in the +# header of bin/forge.sh. Only this file requires gh. +# +# see: bin/forge.sh + +# forge-github:require fails when gh is missing or not authenticated. It makes a real API call, because +# `gh auth status` passes with a revoked token. +forge-github:require() { + guard:require tool:gh || return 1 + if ! gh api user --jq '.login' >/dev/null 2>&1 + then + error "run: gh auth login (the GitHub CLI is not authenticated)" + return 1 + fi +} + +forge-github:repo() { + gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null \ + || { error "run: gh repo view (this checkout does not resolve to a GitHub repository)"; return 1; } +} + +forge-github:repo-url() { + gh repo view --json url --jq .url 2>/dev/null \ + || { error "run: gh repo view (this checkout does not resolve to a GitHub repository)"; return 1; } +} + +forge-github:noun() { printf '#\tpull request\n'; } + +forge-github:pr-url() { printf '%s/pull/%s\n' "$(forge-github:repo-url)" "$1"; } + +forge-github:me() { + gh api user --jq '.login' 2>/dev/null && return 0 + error "run: gh auth login (the GitHub CLI is not authenticated)" + return 1 +} + +forge-github:issue-create() { + local number + number=$(gh issue create --title "$1" --body "" 2>/dev/null | sed -n 's@.*/issues/@@p') + if [[ ! "${number}" =~ ^[0-9]+$ ]] + then + error "run: gh auth status (the issue could not be created)" + return 1 + fi + printf '%s\n' "${number}" +} + +forge-github:issue-title() { + gh issue view "$1" --json title --jq '.title' 2>/dev/null \ + || { error "run: gh auth status (the title of #$1 could not be read)"; return 1; } +} + +forge-github:issue-state() { + gh issue view "$1" --json state --jq .state 2>/dev/null +} + +forge-github:issue-assignees() { + gh issue view "$1" --json assignees --jq '[.assignees[].login] | join(" ")' 2>/dev/null +} + +forge-github:issue-assign() { + gh issue edit "$1" --add-assignee "$2" >/dev/null 2>&1 +} + +# Find the branch through an open pull request's head branch, so a second run never creates a duplicate +# --1 branch. +forge-github:branch-for-issue() { + gh pr list --state open --json headRefName \ + --jq "[.[] | select(.headRefName | test(\"^$1-\"))] | .[0].headRefName // empty" 2>/dev/null || true +} + +# gh issue develop names the branch -, links it to the issue under Development, and pushes it. +forge-github:link-branch() { + gh issue develop "$1" --base "$2" >/dev/null +} + +forge-github:pr-for-branch() { + gh pr list --head "$1" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true +} + +forge-github:pr-create() { + local url + url=$(gh pr create --draft --base "$2" --head "$1" --title "$3" --body "") || return 1 + printf '%s\n' "${url##*/}" +} + +forge-github:pr-title() { + gh pr view "$1" --json title --jq '.title' +} + +forge-github:pr-body() { + gh pr view "$1" --json body --jq '.body // ""' +} + +forge-github:pr-set-body() { + gh pr edit "$1" --body "$2" >/dev/null +} + +forge-github:pr-state-for-branch() { + gh pr list --head "$1" --state all --json state --jq '.[0].state // empty' 2>/dev/null || true +} + +# On a GitHub Actions runner, the event payload gives the pull request. Otherwise gh finds the pull request for +# the current branch. +forge-github:pr-here() { + local number='' + if [[ -n "${GITHUB_EVENT_PATH:-}" ]] + then + number=$(jq --raw-output '.pull_request.number // empty' "${GITHUB_EVENT_PATH}" 2>/dev/null || true) + fi + [[ -n "${number}" ]] || number=$(gh pr view --json number --jq .number 2>/dev/null || true) + printf '%s\n' "${number}" +} + +forge-github:pr-closing-issues() { + local repo query + repo=$(forge-github:repo) || return 1 + query='query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100) { nodes { number state repository { nameWithOwner } } } + } + } + }' + gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --field number="$1" \ + --raw-field query="${query}" \ + --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] + | "\(.number) \(.state) \(.repository.nameWithOwner)"' 2>/dev/null \ + || { error "run: gh auth status (pull request #$1 could not be read)"; return 1; } +} + +# forge-github:open-prs makes one GraphQL request for every open pull request, with the issues it closes, its +# review threads, and the checks on its head commit. It prints one JSON object per pull request, the document +# kit status renders. The fields: +# +# number, ref, branch, title, author, createdAt +# age seconds since the pull request was created +# state draft, queued, conflicted, failing:gate, no-check, comments:open, blocked:approval, or ready +# checks {state, passed, total, failing, took}, where took is in seconds or null +# unresolved the number of unresolved review threads +# closes [{number, title}] of the issues it closes +forge-github:open-prs() { + local repo query filter + repo=$(forge-github:repo) || return 1 + query='query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + number title isDraft headRefName createdAt mergeStateStatus reviewDecision + author { login } + closingIssuesReferences(first: 10) { nodes { number title } } + reviewThreads(first: 100) { nodes { isResolved } } + commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { + __typename + ... on CheckRun { name status conclusion startedAt completedAt } + ... on StatusContext { context state createdAt } + } } } } } } + } + } + } + }' + filter=' + def verdict: if .__typename == "CheckRun" then (if .status == "COMPLETED" then .conclusion else "PENDING" end) + else .state end; + def pending: IN("PENDING", "EXPECTED"); + def passed: IN("SUCCESS", "NEUTRAL", "SKIPPED"); + (now | floor) as $now + | .data.repository.pullRequests.nodes[] + | [.commits.nodes[0].commit.statusCheckRollup.contexts.nodes[]?] as $checks + | ($checks | length) as $n + | ([$checks[] | verdict | select(pending)] | length) as $running + | ([$checks[] | verdict | select(passed)] | length) as $ok + | ([$checks[] | select((verdict | pending or passed) | not) | (.name // .context)] | first // "") as $failing + | (if $n == 0 then "NONE" elif $failing != "" then "FAILURE" elif $running > 0 then "PENDING" else "SUCCESS" end) + as $check + | ([$checks[] | .startedAt // .createdAt // empty | fromdateiso8601] | min) as $started + | ([$checks[] | .completedAt // empty | fromdateiso8601] | max) as $completed + | (if $started == null then null + elif $check == "PENDING" then $now - $started + elif $completed == null then null + else $completed - $started end) as $took + | ([.reviewThreads.nodes[]? | select(.isResolved | not)] | length) as $unresolved + | (if .isDraft then "draft" + elif $check == "PENDING" then "queued" + elif .mergeStateStatus == "DIRTY" then "conflicted" + elif $check == "FAILURE" then "failing:gate" + elif $check == "NONE" then "no-check" + elif $unresolved > 0 then "comments:open" + elif (.reviewDecision // "") | IN("REVIEW_REQUIRED", "CHANGES_REQUESTED") then "blocked:approval" + else "ready" end) as $state + | { number, ref: "#\(.number)", branch: .headRefName, title, author: (.author.login // ""), createdAt, + age: ($now - (.createdAt | fromdateiso8601)), state: $state, + checks: { state: $check, passed: $ok, total: $n, failing: $failing, took: $took }, + unresolved: $unresolved, + closes: [.closingIssuesReferences.nodes[] | { number, title }] } + | tojson' + gh api graphql --field owner="${repo%%/*}" --field name="${repo#*/}" --raw-field query="${query}" --jq "${filter}" \ + 2>/dev/null || { error "run: gh auth status (the pull requests could not be read)"; return 1; } +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/forge-gitlab.sh b/bin/forge-gitlab.sh new file mode 100644 index 0000000..1d0e14c --- /dev/null +++ b/bin/forge-gitlab.sh @@ -0,0 +1,219 @@ +# shellcheck shell=bash +# forge-gitlab.sh - the GitLab forge provider, through the glab CLI (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# shellcheck disable=SC2016 # jq filters name their variables with $, in single quotes on purpose +# +# bin/forge.sh loads this provider when the repository's forge is gitlab: +# +# - gitlab.com, detected from origin's host +# - a self-hosted GitLab, set with `forge: gitlab` in .kit/settings.yml; glab finds the host from origin +# +# It implements the calls listed in the header of bin/forge.sh, with the same output as the GitHub provider. The +# GitLab equivalents: +# +# pull request a merge request +# number the merge request's iid +# checks the jobs of its pipeline +# review threads its resolvable discussions +# +# Only this file requires glab. It reads the REST API through `glab api` and builds every result here with jq, so +# all of the provider's logic is in this file and a test can drive it. +# +# see: bin/forge.sh + +# forge-gitlab:project prints the project's full path, read from origin: group/subgroup/name. +forge-gitlab:project() { + local url + url=$(git remote get-url origin 2>/dev/null) || { error "this checkout has no origin; add one"; return 1; } + sed -E 's#^[a-z]+://[^/]+/##; s#^[^@/]+@[^:]+:##; s#\.git$##' <<<"${url}" +} + +# forge-gitlab:api […] calls the REST API under the project, with the +# project's path URL-encoded as GitLab expects. +forge-gitlab:api() { + local path=$1 project + shift + project=$(forge-gitlab:project) || return 1 + glab api "projects/${project//\//%2F}/${path}" "$@" +} + +forge-gitlab:require() { + guard:require tool:glab || return 1 + if ! glab api user >/dev/null 2>&1 + then + error "run: glab auth login (the GitLab CLI is not authenticated)" + return 1 + fi +} + +forge-gitlab:repo() { forge-gitlab:project; } + +forge-gitlab:repo-url() { + local host + host=$(git remote get-url origin 2>/dev/null | sed -E 's#^([a-z]+://)?([^@/]+@)?([^/:]+).*#\3#') + printf 'https://%s/%s\n' "${host}" "$(forge-gitlab:project)" +} + +forge-gitlab:noun() { printf '!\tmerge request\n'; } + +forge-gitlab:pr-url() { printf '%s/-/merge_requests/%s\n' "$(forge-gitlab:repo-url)" "$1"; } + +forge-gitlab:me() { + glab api user 2>/dev/null | jq --raw-output '.username' && return 0 + error "run: glab auth login (the GitLab CLI is not authenticated)" + return 1 +} + +forge-gitlab:issue-create() { + local number + number=$(glab issue create --title "$1" --description "" --yes 2>/dev/null \ + | sed -n 's@.*/-/issues/\([0-9]*\).*@\1@p' | tail -n 1) + if [[ ! "${number}" =~ ^[0-9]+$ ]] + then + error "run: glab auth status (the issue could not be created)" + return 1 + fi + printf '%s\n' "${number}" +} + +forge-gitlab:issue-title() { + forge-gitlab:api "issues/$1" 2>/dev/null | jq --raw-output '.title' \ + || { error "run: glab auth status (the title of #$1 could not be read)"; return 1; } +} + +forge-gitlab:issue-state() { + forge-gitlab:api "issues/$1" 2>/dev/null | jq --raw-output 'if .state == "closed" then "CLOSED" else "OPEN" end' +} + +forge-gitlab:issue-assignees() { + forge-gitlab:api "issues/$1" 2>/dev/null | jq --raw-output '[.assignees[]?.username] | join(" ")' +} + +forge-gitlab:issue-assign() { + glab issue update "$1" --assignee "+$2" >/dev/null 2>&1 +} + +forge-gitlab:branch-for-issue() { + forge-gitlab:api "merge_requests?state=opened&per_page=100" 2>/dev/null \ + | jq --raw-output --arg n "$1" \ + '[.[] | select(.source_branch | startswith($n + "-"))] | .[0].source_branch // empty' \ + || true +} + +# GitLab has no command that creates a branch for an issue. This creates the branch the issue page would: +# -, from the default branch. GitLab links a branch with that name to the issue. +forge-gitlab:link-branch() { + local title slug + title=$(forge-gitlab:issue-title "$1") || return 1 + slug=$(tr '[:upper:]' '[:lower:]' <<<"${title}" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c 1-60) + slug=${slug%-} + forge-gitlab:api repository/branches --method POST --raw-field "branch=$1-${slug}" --raw-field "ref=$2" >/dev/null +} + +forge-gitlab:pr-for-branch() { + forge-gitlab:api "merge_requests?state=opened&source_branch=$1" 2>/dev/null \ + | jq --raw-output '.[0].iid // empty' || true +} + +forge-gitlab:pr-create() { + local number + number=$(glab mr create --draft --source-branch "$1" --target-branch "$2" --title "$3" --description "" --yes \ + | sed -n 's@.*/-/merge_requests/\([0-9]*\).*@\1@p' | tail -n 1) || return 1 + [[ "${number}" =~ ^[0-9]+$ ]] || { error "the merge request could not be opened; see above"; return 1; } + printf '%s\n' "${number}" +} + +forge-gitlab:pr-title() { + forge-gitlab:api "merge_requests/$1" | jq --raw-output '.title' +} + +forge-gitlab:pr-body() { + forge-gitlab:api "merge_requests/$1" | jq --raw-output '.description // ""' +} + +forge-gitlab:pr-set-body() { + forge-gitlab:api "merge_requests/$1" --method PUT --raw-field "description=$2" >/dev/null +} + +forge-gitlab:pr-state-for-branch() { + forge-gitlab:api "merge_requests?state=all&source_branch=$1&order_by=created_at&sort=desc" 2>/dev/null \ + | jq --raw-output '.[0].state // empty | {merged: "MERGED", closed: "CLOSED", locked: "CLOSED"}[.] // "OPEN"' \ + || true +} + +# On a GitLab CI runner, CI_MERGE_REQUEST_IID gives the merge request. Otherwise it is the open merge request for +# the current branch. +forge-gitlab:pr-here() { + local number=${CI_MERGE_REQUEST_IID:-} branch + if [[ -z "${number}" ]] + then + branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) + [[ -n "${branch}" ]] && number=$(forge-gitlab:pr-for-branch "${branch}") + fi + printf '%s\n' "${number}" +} + +forge-gitlab:pr-closing-issues() { + forge-gitlab:api "merge_requests/$1/closes_issues" 2>/dev/null \ + | jq --raw-output '.[] | "\(.iid) \(if .state == "closed" then "CLOSED" else "OPEN" end) \(.references.full + | sub("#[0-9]+$"; ""))"' \ + || { error "run: glab auth status (merge request !$1 could not be read)"; return 1; } +} + +# forge-gitlab:open-prs prints one JSON object per open merge request, the document kit status renders. The fields +# match forge-github:open-prs: number, ref, branch, title, author, createdAt, age, state, checks {state, passed, +# total, failing, took}, unresolved, and closes. GitLab's REST API needs four reads per merge request: +# +# - the merge request +# - its pipeline's jobs +# - its discussions +# - the issues it closes +forge-gitlab:open-prs() { + local list iid mr jobs discussions closes pipeline + list=$(forge-gitlab:api "merge_requests?state=opened&per_page=100&order_by=created_at&sort=asc" 2>/dev/null) \ + || { error "run: glab auth status (the merge requests could not be read)"; return 1; } + while IFS= read -r iid + do + [[ -n "${iid}" ]] || continue + mr=$(forge-gitlab:api "merge_requests/${iid}") || return 1 + pipeline=$(jq --raw-output '.head_pipeline.id // empty' <<<"${mr}") + jobs='[]' + [[ -n "${pipeline}" ]] && jobs=$(forge-gitlab:api "pipelines/${pipeline}/jobs?per_page=100") + discussions=$(forge-gitlab:api "merge_requests/${iid}/discussions?per_page=100") + closes=$(forge-gitlab:api "merge_requests/${iid}/closes_issues") + jq --compact-output --null-input --argjson mr "${mr}" --argjson jobs "${jobs}" \ + --argjson discussions "${discussions}" --argjson closes "${closes}" ' + def pending: IN("created", "pending", "running", "preparing", "waiting_for_resource", "scheduled"); + def passed: IN("success", "skipped", "manual"); + (now | floor) as $now + | ($jobs | length) as $n + | ([$jobs[] | select(.status | pending)] | length) as $running + | ([$jobs[] | select(.status | passed)] | length) as $ok + | ([$jobs[] | select((.status | pending or passed) | not) | .name] | first // "") as $failing + | (if $n == 0 then "NONE" elif $failing != "" then "FAILURE" elif $running > 0 then "PENDING" else "SUCCESS" end) + as $check + | ([$jobs[] | .started_at // empty | sub("\\.[0-9]+"; "") | fromdateiso8601] | min) as $started + | ([$jobs[] | .finished_at // empty | sub("\\.[0-9]+"; "") | fromdateiso8601] | max) as $completed + | (if $started == null then null + elif $check == "PENDING" then $now - $started + elif $completed == null then null + else $completed - $started end) as $took + | ([$discussions[] | .notes[]? | select(.resolvable and (.resolved | not))] | length) as $unresolved + | (if $mr.draft then "draft" + elif $check == "PENDING" then "queued" + elif $mr.has_conflicts then "conflicted" + elif $check == "FAILURE" then "failing:gate" + elif $check == "NONE" then "no-check" + elif $unresolved > 0 then "comments:open" + elif ($mr.detailed_merge_status // "") == "not_approved" then "blocked:approval" + else "ready" end) as $state + | { number: $mr.iid, ref: "!\($mr.iid)", branch: $mr.source_branch, title: $mr.title, + author: ($mr.author.username // ""), createdAt: $mr.created_at, + age: ($now - ($mr.created_at | sub("\\.[0-9]+"; "") | fromdateiso8601)), state: $state, + checks: { state: $check, passed: $ok, total: $n, failing: $failing, took: $took }, + unresolved: $unresolved, + closes: [$closes[] | { number: .iid, title }] }' + done < <(jq --raw-output '.[].iid' <<<"${list}") +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/forge.sh b/bin/forge.sh new file mode 100644 index 0000000..50aa6d6 --- /dev/null +++ b/bin/forge.sh @@ -0,0 +1,114 @@ +# shellcheck shell=bash +# forge.sh - one interface to the forge (GitHub, GitLab) that hosts the repository (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Verbs that read or change issues, branches, and pull requests call forge: here. Each call is passed to a +# provider, forge-.sh in this directory, whose functions are named forge-:. +# +# The provider is chosen by: +# +# 1. the `forge` setting in .kit/settings.yml, if set +# 2. otherwise origin's host: github.com is github, gitlab.com is gitlab +# +# kit install, update, check, context, and lint never call the forge, so they work in a repository that has no +# forge. +# +# Every provider implements the calls below. Each prints its result on stdout. On failure it prints a remedy on +# stderr. A call marked "or nothing" prints nothing when there is no match. +# +# require the provider's own tool is present and authenticated +# repo the repository as owner/name +# repo-url the repository's browser URL +# noun the change's reference sigil and its name, tab-separated: "#\tpull request" +# pr-url the change's browser URL +# me the caller's login +# issue-create the number of the issue it creates +# issue-title <n> the issue's title +# issue-state <n> OPEN or CLOSED +# issue-assignees <n> the assignees' logins, space-separated +# issue-assign <n> <login> adds the assignee +# branch-for-issue <n> the branch an open pull request for issue <n> is on, or nothing +# link-branch <n> <default> makes a branch linked to the issue, from <default>, pushed to origin +# pr-for-branch <branch> the number of the open pull request on the branch, or nothing +# pr-create <branch> <default> <title> opens a draft pull request and prints its number +# pr-title <n> the pull request's title +# pr-body <n> the pull request's body +# pr-set-body <n> <body> replaces it +# pr-state-for-branch <branch> MERGED, CLOSED, or OPEN for the latest pull request on the branch, or nothing +# pr-here the number of the pull request this checkout is working on, or nothing +# pr-closing-issues <n> one line per issue the pull request closes: "<number> <state> <owner/name>" +# open-prs one JSON object per open pull request, the documents kit status draws +# +# see: docs/architecture.md + +use release + +# forge:provider prints the provider's name: the forge setting, else github for an origin at github.com and +# gitlab for one at gitlab.com. A self-hosted forge needs the setting. It fails with a remedy when neither gives +# a provider the kit has. A repository hosted on two forges (a mirror, or mid-migration) uses the setting to pick +# one. +forge:provider() { + local provider host='' + provider=$(release:setting forge) + if [[ -z "${provider}" ]] + then + host=$(git remote get-url origin 2>/dev/null | sed -E 's#^([a-z]+://)?([^@/]+@)?([^/:]+).*#\3#') + case "${host}" in + (github.com | *.github.com) provider=github ;; + (gitlab.com | *.gitlab.com) provider=gitlab ;; + esac + fi + if [[ -z "${provider}" ]] + then + error "set forge in .kit/settings.yml; origin's host '${host:-none}' names no forge the kit knows" \ + "($(forge:providers))" + return 1 + fi + # shellcheck disable=SC2154 # kit_bin_dir is declared by bin/1st.sh, which loaded this module + if [[ ! -f "${kit_bin_dir}/forge-${provider}.sh" ]] + then + error "'${provider}' is not a forge the kit knows; the forges are: $(forge:providers) (forge in .kit/settings.yml)" + return 1 + fi + printf '%s\n' "${provider}" +} + +# forge:providers prints the providers this release carries, space-separated. +forge:providers() { + (cd "${kit_bin_dir}" && find . -maxdepth 1 -name 'forge-*.sh' | sed 's@^\./forge-@@; s@\.sh$@@' | LC_ALL=C sort \ + | paste -s -d ' ' -) +} + +# forge:call <call> <argument>… runs the provider's function for <call>, loading the provider once. +forge:call() { + local call=$1 provider + shift + provider=$(forge:provider) || return 1 + use "forge-${provider}" + "forge-${provider}:${call}" "$@" +} + +forge:require() { forge:call require "$@"; } +forge:repo() { forge:call repo "$@"; } +forge:repo-url() { forge:call repo-url "$@"; } +forge:me() { forge:call me "$@"; } +forge:noun() { forge:call noun "$@"; } +forge:pr-url() { forge:call pr-url "$@"; } +forge:issue-create() { forge:call issue-create "$@"; } +forge:issue-title() { forge:call issue-title "$@"; } +forge:issue-state() { forge:call issue-state "$@"; } +forge:issue-assignees() { forge:call issue-assignees "$@"; } +forge:issue-assign() { forge:call issue-assign "$@"; } +forge:branch-for-issue() { forge:call branch-for-issue "$@"; } +forge:link-branch() { forge:call link-branch "$@"; } +forge:pr-for-branch() { forge:call pr-for-branch "$@"; } +forge:pr-create() { forge:call pr-create "$@"; } +forge:pr-title() { forge:call pr-title "$@"; } +forge:pr-body() { forge:call pr-body "$@"; } +forge:pr-set-body() { forge:call pr-set-body "$@"; } +forge:pr-state-for-branch() { forge:call pr-state-for-branch "$@"; } +forge:pr-here() { forge:call pr-here "$@"; } +forge:pr-closing-issues() { forge:call pr-closing-issues "$@"; } +forge:open-prs() { forge:call open-prs "$@"; } + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/generate.sh b/bin/generate.sh new file mode 100644 index 0000000..8c24f10 --- /dev/null +++ b/bin/generate.sh @@ -0,0 +1,396 @@ +# shellcheck shell=bash +# generate.sh - fill each kit:generate region with its command's output, until nothing changes (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# shellcheck disable=SC2154 # kit_root is exported by bin/kit +# shellcheck disable=SC2016 # the $ in a pattern anchors a line; nothing is meant to expand +# +# A region starts at a directive line and ends at an end line: +# +# - opened by a line that is `kit:generate <name>`, or `kit:generate <name> `<command>`` +# - closed by a line that is `kit:end <name>` +# +# Only non-alphanumeric characters may come before or after the marker on its line. So a directive works inside +# any comment syntax without the kit knowing that syntax: `#`, `//`, `--`, `;`, `%`, `'`, `<!-- -->`, `/* */`, +# and any other, since comment delimiters are punctuation. A line of prose has letters before the marker, so it is +# not read at all. +# +# The directive and end lines stay. The command's standard output replaces the lines between them. With no +# command, the command is `kit region`, which reads the region's name as the path of a region an active module +# delivers. +# +# The command is a bash command line, pipes included. It runs: +# +# - from the repository root +# - with the running kit's bin/ first on PATH +# - with stdin closed +# - with errexit and pipefail on +# - with KIT_GENERATE_FILE and KIT_GENERATE_REGION set to the file and the region's name +# +# A command cannot contain a literal backtick. Use $() instead. +# +# The file and each command's output are expanded as separate units, and each must be balanced on its own. An +# output may open regions of its own if it also closes them. Those regions are expanded in turn, depth first, so +# regions can nest and one pass settles a file. In markdown, directives inside a fenced code block are ignored, so +# a page can show a directive as an example. +# +# The file is rewritten only when the whole expansion succeeds. Otherwise the file is left unchanged and the run +# fails, naming the file and the line. The run fails when: +# +# - a command fails +# - a unit closes a region it did not open, or opens one it does not close +# - a line starts with the marker but is not a valid directive, so a misspelled directive is never silently +# skipped +# - a region name is opened twice +# - in markdown, a fenced code block opens and never closes (in the file or in an output), since the directives +# after it could not be read +# - the file contains an unresolved conflict marker +# +# see: bin/kit-generate.sh, bin/kit-region.sh, docs/generate.md + +# The directive patterns. A line is a directive only when nothing but non-alphanumeric characters (the comment +# opener and closer) come before and after the marker. The name is any run of characters without whitespace or a +# backtick. The command is everything between the first backtick after the name and the last backtick on the line. +declare -g generate_open_re='^[^[:alnum:]]*kit:generate[[:space:]]+([^[:space:]`]+)([[:space:]]+`(.+)`)?[^[:alnum:]]*$' +declare -g generate_end_re='^[^[:alnum:]]*kit:end[[:space:]]+([^[:space:]`]+)[^[:alnum:]]*$' +# A line that starts with a marker must be a valid directive, or it is an error. It is never read as prose. +declare -g generate_marker_re='^[^[:alnum:]]*kit:(generate|end)([^[:alnum:]]|$)' +declare -g generate_fence_re='^[[:space:]]*(```|~~~)' + +# An unresolved merge conflict: a line of seven < or seven >, alone or followed by git's label. The middle marker +# (seven =) is not matched: it is also a markdown heading underline, and git never writes it without the opening one. +declare -g generate_conflict_re='^(<{7}|>{7})([[:space:]].*)?$' + +# generate:carries <file> is true when the file has a line that starts with a marker. Files under .kit/templates/ +# never count: a template is the skeleton of a file, and its directive belongs to the file made from it. +generate:carries() { + [[ -f "$1" && ! "$1" =~ (^|/)\.kit/templates/ ]] && grep --quiet --extended-regexp "${generate_marker_re}" "$1" +} + +# generate:markdown <file> is true for a markdown file (.md or .markdown), the only kind where fenced code blocks +# are skipped. +generate:markdown() { + [[ "$1" == *.md || "$1" == *.markdown ]] +} + +# generate:discover [<path>…] prints every file that contains a directive. With no paths, it searches tracked +# files and untracked files git does not ignore, so a file kit install just wrote counts before it is added. With +# paths, it prints each named file that contains a directive, wherever it is. +generate:discover() { + local path + if (( $# == 0 )) + then + while IFS= read -r -d '' path + do + generate:carries "${path}" && printf '%s\n' "${path}" + done < <(git ls-files -z --cached --others --exclude-standard) + return 0 + fi + for path in "$@" + do + generate:carries "${path}" && printf '%s\n' "${path}" + done + return 0 +} + +# generate:target <file> prints the file a write goes to: <file> itself, or the target of a symlink at <file>. +generate:target() { + if [[ -L "$1" ]] + then readlink -f "$1" + else printf '%s\n' "$1" + fi +} + +# generate:command <file> <where> <line> <name> <command> <out> runs one region's command and writes its stdout +# to <out>. On failure it prints an error naming <where> and <line> (the unit and line of the directive). +generate:command() { + local file=$1 where=$2 number=$3 name=$4 command=$5 out=$6 + if ! KIT_GENERATE_FILE="${file}" KIT_GENERATE_REGION="${name}" PATH="${kit_root}/bin:${PATH}" \ + bash -o errexit -o pipefail -c "${command}" </dev/null >"${out}" + then + error "${where}:${number}: the command failed: ${command}" + return 2 + fi +} + +# generate:flip toggles the caller's fence variable (true inside a fenced code block) at a fence line. +generate:flip() { + if [[ "${fence}" == true ]] + then fence=false + else fence=true + fi +} + +# generate:unit <in> <out> <file> <where> <markdown> expands one unit (the file, or a command's output) from <in> +# into <out>. Every message starts with <where>: the file's path for the file, or, for an output, the file, the +# directive's line, and the command. It makes two passes: +# +# 1. check the unit's shape: every marker line is a directive, and every region opens and closes in this unit, +# in order +# 2. run each top-level region's command, expand its output as a unit of its own, and write the result between +# the directive and end lines +generate:unit() { + local in=$1 out=$2 file=$3 where=$4 markdown=$5 + local -a lines stack opened + local n i j line fence=false fenced=0 depth name command body result + mapfile -t lines < "${in}" + n=${#lines[@]} + stack=() + opened=() + for (( i = 0; i < n; i++ )) + do + line=${lines[i]} + if [[ "${markdown}" == true && "${line}" =~ ${generate_fence_re} ]] + then + generate:flip + [[ "${fence}" == true ]] && fenced=$(( i + 1 )) + continue + fi + [[ "${fence}" == false && "${line}" =~ ${generate_marker_re} ]] || continue + if [[ "${line}" =~ ${generate_open_re} ]] + then + stack+=("${BASH_REMATCH[1]}") + opened+=("$(( i + 1 ))") + elif [[ "${line}" =~ ${generate_end_re} ]] + then + name=${BASH_REMATCH[1]} + if (( ${#stack[@]} == 0 )) + then + error "${where}:$(( i + 1 )): closes '${name}', which is not open" + return 2 + fi + if [[ "${stack[-1]}" != "${name}" ]] + then + error "${where}:$(( i + 1 )): closes '${name}' while '${stack[-1]}' is open" + return 2 + fi + unset 'stack[-1]' 'opened[-1]' + else + error "${where}:$(( i + 1 )): not a directive: ${line}" + return 2 + fi + done + if (( ${#stack[@]} > 0 )) + then + error "${where}:${opened[-1]}: the region '${stack[-1]}' never closes" + return 2 + fi + if [[ "${fence}" == true ]] + then + error "${where}: a fenced code block opens at line ${fenced} and never closes" + return 2 + fi + : > "${out}" + fence=false + i=0 + while (( i < n )) + do + line=${lines[i]} + printf '%s\n' "${line}" >> "${out}" + if [[ "${markdown}" == true && "${line}" =~ ${generate_fence_re} ]] + then + generate:flip + i=$(( i + 1 )) + continue + fi + if [[ "${fence}" == true ]] || ! [[ "${line}" =~ ${generate_open_re} ]] + then + i=$(( i + 1 )) + continue + fi + name=${BASH_REMATCH[1]} + command=${BASH_REMATCH[3]:-kit region} + body=$(mktemp) + result=$(mktemp) + if ! generate:command "${file}" "${where}" "$(( i + 1 ))" "${name}" "${command}" "${body}" \ + || ! generate:unit "${body}" "${result}" "${file}" "${where}:$(( i + 1 )) (the output of ${command})" "${markdown}" + then + rm -f "${body}" "${result}" + return 2 + fi + cat "${result}" >> "${out}" + rm -f "${body}" "${result}" + # Skip the old body up to this region's end line, counting depth. The first pass already checked the nesting. + depth=1 + for (( j = i + 1; depth > 0; j++ )) + do + line=${lines[j]} + if [[ "${markdown}" == true && "${line}" =~ ${generate_fence_re} ]] + then + generate:flip + continue + fi + [[ "${fence}" == false ]] || continue + if [[ "${line}" =~ ${generate_open_re} ]] + then depth=$(( depth + 1 )) + elif [[ "${line}" =~ ${generate_end_re} ]] + then depth=$(( depth - 1 )) + fi + done + printf '%s\n' "${lines[j - 1]}" >> "${out}" + i=${j} + done +} + +# generate:twice <expanded> <file> prints "<name>\t<line>" for the first region the expanded text opens a second +# time, or nothing. When <file> is markdown, fenced code blocks are skipped. +generate:twice() { + local expanded=$1 line fence=false markdown=false number=0 + local -A seen=() + generate:markdown "$2" && markdown=true + while IFS= read -r line || [[ -n "${line}" ]] + do + number=$(( number + 1 )) + if [[ "${markdown}" == true && "${line}" =~ ${generate_fence_re} ]] + then + generate:flip + continue + fi + [[ "${fence}" == false && "${line}" =~ ${generate_open_re} ]] || continue + if [[ -n "${seen[${BASH_REMATCH[1]}]+x}" ]] + then + printf '%s\t%s\n' "${BASH_REMATCH[1]}" "${number}" + return 0 + fi + seen[${BASH_REMATCH[1]}]=1 + done < "${expanded}" +} + +# generate:expand <file> <mode> expands the file's regions into a temp file beside it and compares the two. Only +# mode write writes the file. It returns: +# +# 0 nothing changed +# 1 the file was rewritten (mode write) or is stale (mode check) +# 2 the file could not be expanded cleanly, and is left unchanged +generate:expand() { + local file=$1 mode=${2:-write} target temp markdown=false twice number + if [[ ! -f "${file}" ]] + then + error "generate: no such file: ${file}" + return 2 + fi + if grep --quiet --extended-regexp "${generate_conflict_re}" "${file}" + then + error "generate: ${file} carries an unresolved conflict; resolve it, then run: kit generate" + return 2 + fi + target=$(generate:target "${file}") + temp="${target}.kit-new" + cp -p "${target}" "${temp}" + generate:markdown "${file}" && markdown=true + if ! generate:unit "${file}" "${temp}" "${file}" "${file}" "${markdown}" + then + rm -f "${temp}" + return 2 + fi + IFS=$'\t' read -r twice number < <(generate:twice "${temp}" "${file}") + if [[ -n "${twice}" ]] + then + error "${file}: the region '${twice}' is opened twice, the second time at line ${number} of the expanded file" + rm -f "${temp}" + return 2 + fi + if cmp --silent "${file}" "${temp}" + then + rm -f "${temp}" + return 0 + fi + if [[ "${mode}" == check ]] + then + error "${file}: stale; run: kit generate" + rm -f "${temp}" + return 1 + fi + mv "${temp}" "${target}" + step "${file}: regenerated" + return 1 +} + +# generate:run [--check] [--iterations=<n>] [<path>…] is the driver: +# +# - Named files get one pass, or up to <n>. +# - With no paths, every file containing a directive is expanded until a pass changes nothing, for at most 20 +# passes or <n>. More than one pass can be needed because a command may read another file's region. +# - --check runs one pass without writing and fails naming every stale file. +# +# A file that cannot be expanded cleanly fails the run in every mode. +generate:run() { + local check=false limit=0 explicit=false arg file rc pass=0 changed failed stale + local -a files=() targets=() + for arg in "$@" + do + case "${arg}" in + (--check) check=true ;; + (--iterations=*) limit=${arg#*=} ;; + (*) files+=("${arg}"); explicit=true ;; + esac + done + if (( ${#files[@]} )) + then readarray -t targets < <(generate:discover "${files[@]}") + else readarray -t targets < <(generate:discover) + fi + if (( ${#targets[@]} == 0 )) + then + ok "generate: no kit:generate directive to expand" + return 0 + fi + if [[ "${check}" == true ]] + then + stale=0 + failed=0 + for file in "${targets[@]}" + do + generate:expand "${file}" check && rc=0 || rc=$? + (( rc == 2 )) && failed=$(( failed + 1 )) + (( rc == 1 )) && stale=$(( stale + 1 )) + done + if (( failed > 0 )) + then + error "generate: ${failed} file(s) could not be expanded; see above" + return 1 + fi + if (( stale > 0 )) + then + error "generate: ${stale} of ${#targets[@]} generated file(s) are stale; run: kit generate" + return 1 + fi + ok "generate: ${#targets[@]} generated file(s), every region as its command makes it" + return 0 + fi + if (( limit == 0 )) && [[ "${explicit}" == true ]] + then limit=1 + elif (( limit == 0 )) + then limit=20 + fi + while (( pass < limit )) + do + pass=$(( pass + 1 )) + changed=0 + failed=0 + for file in "${targets[@]}" + do + generate:expand "${file}" write && rc=0 || rc=$? + (( rc == 2 )) && failed=$(( failed + 1 )) + (( rc == 1 )) && changed=$(( changed + 1 )) + done + if (( failed > 0 )) + then + error "generate: ${failed} file(s) could not be expanded; see above" + return 1 + fi + if (( changed == 0 )) + then + ok "generate: ${#targets[@]} generated file(s), settled in ${pass} pass(es)" + return 0 + fi + done + if [[ "${explicit}" == true ]] + then + ok "generate: ${#targets[@]} file(s) expanded in ${pass} pass(es)" + return 0 + fi + error "generate: still changing after ${limit} passes; a command's output depends on its own output." \ + "Raise the bound with --iterations=<n>, or fix the command." + return 1 +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/git.sh b/bin/git.sh new file mode 100644 index 0000000..a9ae45d --- /dev/null +++ b/bin/git.sh @@ -0,0 +1,268 @@ +# shellcheck shell=bash +# git.sh - git helpers: the default branch, worktrees, kit tidy, and kit rebase (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit tidy (git:tidy) decides whether a worktree's work is finished by asking: +# +# - the forge, the service that hosts the repository (GitHub, GitLab), through bin/forge.sh: is the branch's pull +# request merged or closed? +# - when the branch has no pull request, the issue's tracker, through bin/tracker.sh: is the branch's issue +# closed? + +use forge + +# git:default-branch [<path>] prints the default branch of the repository at <path>: origin/HEAD, else main. +git:default-branch() { + local branch + branch=$(git -C "${1:-.}" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' \ + || true) + [[ -n "${branch}" ]] || branch=main + printf '%s\n' "${branch}" +} + +# git:main-root [<path>] prints the root of the main checkout, which contains worktrees/. It works from the main +# checkout or from any linked worktree. +git:main-root() { + dirname "$(git -C "${1:-.}" rev-parse --path-format=absolute --git-common-dir)" +} + +# git:worktrees prints "<path>\t<detached>\t<branch>" for every linked worktree under worktrees/. <detached> is +# true or false. <branch> is empty for a detached checkout. The empty field comes last so a tab-separated read +# keeps it. +git:worktrees() { + local worktree_root path="" branch="" detached=false line + worktree_root="$(git:main-root)/worktrees" + while IFS= read -r line + do + case "${line}" in + (worktree\ *) + path=${line#worktree } + branch="" + detached=false + ;; + (branch\ refs/heads/*) + branch=${line#branch refs/heads/} + ;; + (detached) + detached=true + ;; + ("") + # git ends each record with a blank line. The printf below adds one more so the last record is printed too. + [[ "${path}" == "${worktree_root}"/* ]] && printf '%s\t%s\t%s\n' "${path}" "${detached}" "${branch}" + path="" + esac + done < <(git worktree list --porcelain; printf '\n') +} + +# git:gone-branches prints every local branch whose upstream no longer exists. +git:gone-branches() { + git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2 == "[gone]" { print $1 }' +} + +# git:clean <path> is true when the checkout at <path> has no uncommitted changes. +git:clean() { + [[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ]] +} + +# git:tidy-worktree <path> <branch> <detached> <default-branch> <self-root> <dry-run> decides whether to remove +# one worktree and prints the reason. It returns 0 when the worktree was removed (or would be, under a dry run) +# and 1 when it is kept. +git:tidy-worktree() { + local path=$1 branch=$2 detached=$3 default_branch=$4 self_root=$5 dry_run=$6 state="" ref + if [[ "${path}" == "${self_root}" ]] + then + step "keeping ${path} (this command runs in it)" + return 1 + fi + if ! git:clean "${path}" + then + step "keeping ${path} (${branch:-detached}, it has uncommitted changes)" + return 1 + fi + if [[ -n "${branch}" ]] + then + state=$(forge:pr-state-for-branch "${branch}") + if [[ -z "${state}" ]] + then + use tracker + ref=$(tracker:branch-ref "${branch}") + [[ -n "${ref}" && "$(tracker:of "${ref}" state)" == CLOSED ]] && state=CLOSED + fi + elif [[ "${detached}" == true ]] + then + # Remove a detached checkout only when its commit is already on the default branch. Otherwise its HEAD may be + # the only ref that reaches that commit. + if git -C "${path}" merge-base --is-ancestor HEAD "origin/${default_branch}" 2>/dev/null + then + state=DETACHED + else + step "keeping ${path} (detached at a commit origin/${default_branch} does not reach)" + return 1 + fi + fi + case "${state}" in + (MERGED | CLOSED | DETACHED) + if [[ "${dry_run}" == true ]] + then + step "would remove ${path} (${branch:-detached}, ${state})" + else + step "removing ${path} (${branch:-detached}, ${state})" + git worktree remove "${path}" + fi + return 0 + ;; + (*) + step "keeping ${path} (${branch:-detached}, change ${state:-open or unknown})" + return 1 + esac +} + +# git:tidy [--dry-run] cleans up finished work. It: +# +# 1. fetches origin with --prune +# 2. removes every clean worktree under worktrees/ whose pull request is merged or closed (or, with no pull +# request, whose issue is closed), or that is detached at a commit the default branch already has +# 3. prunes git's worktree bookkeeping +# 4. deletes every local branch whose upstream is gone +# +# It never touches: +# +# - the default branch +# - a branch checked out in a worktree it keeps +# - a worktree with uncommitted changes +# - a detached worktree whose commit nothing else reaches +# - the worktree it runs in +# +# --dry-run prints what it would do and changes nothing. +git:tidy() { + local dry_run=false flag repo default_branch self_root found=false path branch detached + local removed_worktrees=0 removed_branches=0 checked_out + local -A freed=() + for flag in "$@" + do + case "${flag}" in + (--dry-run) + dry_run=true + ;; + (*) + error "unknown flag '${flag}' (only --dry-run is recognized)" + return 2 + esac + done + use forge + forge:require || return 1 + repo=$(forge:repo) || return 1 + if [[ "${dry_run}" == true ]] + then + section "kit tidy, dry run: nothing is changed" + else + section "kit tidy: sweeping ${repo} back to pristine" + fi + + default_branch=$(git:default-branch) + self_root=$(git rev-parse --show-toplevel) + if [[ "${dry_run}" == true ]] + then + step "would run: git fetch --prune origin" + else + step "git fetch --prune origin" + git fetch --quiet --prune origin + fi + + section "worktrees whose pull request is merged or closed" + while IFS=$'\t' read -r path detached branch + do + found=true + if git:tidy-worktree "${path}" "${branch}" "${detached}" "${default_branch}" "${self_root}" "${dry_run}" + then + (( ++removed_worktrees )) + [[ -n "${branch}" ]] && freed[${branch}]=1 + fi + done < <(git:worktrees) + [[ "${found}" == true ]] || ok "none" + + section "worktree bookkeeping" + if [[ "${dry_run}" == true ]] + then + git worktree prune --verbose --dry-run || true + else + git worktree prune --verbose || true + fi + ok "pruned" + + section "local branches whose upstream is gone" + checked_out=$(git worktree list --porcelain | sed -n 's@^branch refs/heads/@@p') + while IFS= read -r branch + do + [[ -n "${branch}" ]] || continue + [[ "${branch}" == "${default_branch}" ]] && continue + if [[ -z "${freed[${branch}]+x}" ]] && grep --quiet --line-regexp --fixed-strings "${branch}" <<<"${checked_out}" + then + step "keeping ${branch} (checked out in a worktree)" + continue + fi + if [[ "${dry_run}" == true ]] + then + step "would delete ${branch}" + else + step "deleting ${branch}" + git branch -D "${branch}" >/dev/null + fi + (( ++removed_branches )) + done <<<"$(git:gone-branches)" + (( removed_branches > 0 )) || ok "none" + + if [[ "${dry_run}" == true ]] + then + ok "Dry run: would remove ${removed_worktrees} worktree(s) and ${removed_branches} branch(es)." + else + ok "Tidied ${repo}: removed ${removed_worktrees} worktree(s) and ${removed_branches} branch(es)." + fi +} + +# git:rebase [<path>] rebases the branch checked out at <path> (default: the current checkout) onto the default +# branch at origin, then pushes it with --force-with-lease. It refuses: +# +# - a checkout with no branch checked out +# - the root checkout (the desk, which belongs to the human), whatever branch it has +# - the default branch, in any checkout +# - a worktree with uncommitted changes +# +# On a conflict it aborts the rebase and leaves the worktree as it was. +git:rebase() { + local worktree=${1:-.} branch default_branch + branch=$(git -C "${worktree}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) + if [[ -z "${branch}" ]] + then + error "${worktree} has no branch checked out, so there is nothing to rebase" + return 1 + fi + if [[ "$(git -C "${worktree}" rev-parse --show-toplevel)" == "$(git:main-root "${worktree}")" ]] + then + error "refusing to rebase ${branch}: it is the root checkout, the human's desk; rebase in the branch's worktree" + return 1 + fi + default_branch=$(git:default-branch "${worktree}") + if [[ "${branch}" == "${default_branch}" ]] + then + error "refusing to rebase ${branch}: it is the default branch; it advances by: git pull --ff-only" + return 1 + fi + if ! git:clean "${worktree}" + then + error "refusing to rebase ${branch}: the worktree has uncommitted changes; commit or stash them first" + return 1 + fi + section "kit rebase ${branch} onto origin/${default_branch}" + git -C "${worktree}" fetch --quiet --prune origin + if ! git -C "${worktree}" rebase --quiet "origin/${default_branch}" + then + git -C "${worktree}" rebase --abort 2>/dev/null || true + error "${branch} conflicts with origin/${default_branch}; the rebase was aborted and the worktree is as it was" + return 1 + fi + git -C "${worktree}" push --quiet --force-with-lease + ok "${branch} is rebased onto origin/${default_branch} and pushed." +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/issue.sh b/bin/issue.sh new file mode 100644 index 0000000..fd321c8 --- /dev/null +++ b/bin/issue.sh @@ -0,0 +1,279 @@ +# shellcheck shell=bash +# issue.sh - start an issue: a linked branch, a worktree, a start commit, and a draft pull request (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# issue:start [<reference>…] starts or resumes work on one or more issues. Each reference names its tracker, as +# bin/tracker.sh describes: +# +# - `7` or `#7`: an issue in the forge's own tracker (the forge is the service that hosts the repository, GitHub +# or GitLab) +# - `AB#4521`: a work item in a declared Azure DevOps tracker +# +# The first reference is the primary issue. The others are secondary. Each issue is closed on merge by: +# +# - a primary issue in the forge's tracker: the forge links it to the branch the forge creates +# - any other primary issue, and every secondary one: a `Closes <reference>` line in the pull request's body +# +# Every listed issue is assigned to the caller, as each tracker identifies the caller. +# +# issue:start never switches the checkout it runs from. It checks the branch out in worktrees/<key>-<slug> under +# the main checkout. The key is the reference as it starts a branch name: 7 or ab4521. An existing branch, +# worktree, or pull request is reused, so running it again fills in whatever is missing. +# +# With no argument and a terminal on stdin, it asks for a title and creates the issue. Without a terminal it fails +# with a usage error, so an agent never waits on a prompt. +# +# Uncommitted work is handled by where it is: +# +# - in the root checkout (the desk, which belongs to the human): moved into the new worktree, still uncommitted. +# The desk is left clean, and is fast-forwarded when it is on the default branch. If the work cannot be +# applied in the worktree, it stays in the stash and the command stops. +# - in any other checkout: left in place, since it belongs to that checkout's issue +# +# Every forge call goes through bin/forge.sh, and every issue call through bin/tracker.sh. Each helper below +# prints its one result on stdout and its messages on stderr, so issue:start can capture the results in order. + +use git forge tracker + +# issue:create asks for a title at the terminal, creates the issue in the forge's tracker, and prints its reference. +issue:create() { + local title number + read -r -p "Issue title: " title + if [[ -z "${title}" ]] + then + error "an issue title is required" + return 2 + fi + number=$(tracker:at '#' create "${title}") || return 1 + ok "Created #${number}: ${title}" + printf '#%s\n' "${number}" +} + +# issue:assign <reference>… adds the caller (as each issue's tracker identifies the caller) to each issue's +# assignees. It warns when someone else is already assigned, and when the assignment fails. +issue:assign() { + local ref prefix me assignees + local -A me_in=() + for ref in "$@" + do + prefix=$(tracker:parse "${ref}" | cut -f 1) + [[ -n "${me_in[${prefix}]:-}" ]] || me_in[${prefix}]=$(tracker:at "${prefix}" me) || return 1 + me=${me_in[${prefix}]} + assignees=$(tracker:of "${ref}" assignees) + [[ " ${assignees} " == *" ${me} "* ]] && continue + [[ -z "${assignees}" ]] \ + || warn "${ref} is already assigned to ${assignees}; adding you as well. Coordinate before continuing." + tracker:of "${ref}" assign "${me}" || warn "${ref} could not be assigned to ${me}; assign it by hand." + done +} + +# issue:branch <reference> <default-branch> <title> prints the issue's branch. It reuses an existing branch, so a +# second run never creates a duplicate. It looks, in order, for: +# +# 1. the branch of an open pull request for the issue +# 2. a branch on origin named <key>-* +# +# When neither exists, it creates the branch: +# +# - for an issue in the forge's tracker, the forge creates a branch linked to the issue +# - for any other issue, kit pushes <key>-<slug of the title> to origin from the default branch +issue:branch() { + local ref=$1 default_branch=$2 title=$3 key branch slug + key=$(tracker:key "${ref}") || return 2 + branch=$(forge:branch-for-issue "${key}") + [[ -n "${branch}" ]] \ + || branch=$(git ls-remote --heads origin "${key}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + if [[ -z "${branch}" ]] && tracker:is-forge "${ref}" + then + step "Linking a branch to ${ref}" + forge:link-branch "${key}" "${default_branch}" || return 1 + git fetch --quiet origin + branch=$(git ls-remote --heads origin "${key}-*" 2>/dev/null | sed -n '1s@.*refs/heads/@@p') + if [[ -z "${branch}" ]] + then + error "the branch the forge made for ${ref} could not be found on origin; link one by hand and re-run" + return 1 + fi + elif [[ -z "${branch}" ]] + then + slug=$(tr '[:upper:]' '[:lower:]' <<<"${title}" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c 1-60) + branch="${key}-${slug%-}" + step "Making ${branch} for ${ref} from ${default_branch}" + git push --quiet origin "refs/remotes/origin/${default_branch}:refs/heads/${branch}" || return 1 + git fetch --quiet origin + fi + printf '%s\n' "${branch}" +} + +# issue:worktree <main-root> <branch> prints the worktree the branch is checked out in. If there is none, it +# creates <main-root>/worktrees/<branch>. +issue:worktree() { + local main_root=$1 branch=$2 wt + wt=$(git worktree list --porcelain 2>/dev/null \ + | awk -v b="branch refs/heads/${branch}" '/^worktree / { w = substr($0, 10) } $0 == b { print w; exit }') + if [[ -n "${wt}" ]] + then + step "Reusing worktree ${wt}" + else + wt="${main_root}/worktrees/${branch}" + if [[ -e "${wt}" ]] + then + error "${wt} exists but is not a worktree of ${branch}; remove it (or run: kit tidy) and re-run." + return 2 + fi + step "Creating worktree ${wt}" + mkdir -p "${main_root}/worktrees" + if git show-ref --verify --quiet "refs/heads/${branch}" + then + git worktree add --quiet "${wt}" "${branch}" + else + git worktree add --quiet "${wt}" -b "${branch}" "origin/${branch}" + fi + fi + printf '%s\n' "${wt}" +} + +# issue:start-commit <worktree> <default-branch> <reference> <title> adds an empty first commit when the branch has +# no commits beyond the default branch, because a forge cannot open a pull request with nothing to compare. --only +# keeps the commit empty, so work already staged in a reused worktree stays in the index. The subject starts with +# the reference, as the workflow's commit subjects do: "<reference> start: <title>". +issue:start-commit() { + local wt=$1 default_branch=$2 ref=$3 title=$4 + if [[ -z "$(git -C "${wt}" rev-list "origin/${default_branch}..HEAD" 2>/dev/null)" ]] + then + step "Adding the start commit" + git -C "${wt}" commit --quiet --allow-empty --only --message "${ref} start: ${title}" + fi +} + +# issue:sweep-push <root> <message> stashes all uncommitted work at <root>, including untracked files, and prints +# the stash's commit hash. The hash stays valid even when other entries are pushed onto or dropped from the stash. +issue:sweep-push() { + local root=$1 message=$2 + if ! git -C "${root}" stash push --include-untracked --message "${message}" >/dev/null + then + error "the uncommitted work at ${root} could not be stashed; resolve it by hand and re-run." + return 1 + fi + git -C "${root}" rev-parse 'stash@{0}' +} + +# issue:sweep-apply <root> <worktree> <stash> <default-branch> applies the stash in the worktree, uncommitted, and +# drops it. Then, if the desk is on the default branch, it fast-forwards the now-clean desk. On a conflict it keeps +# the stash and returns non-zero, so the caller stops. +issue:sweep-apply() { + local root=$1 wt=$2 stash=$3 default_branch=$4 slot + if ! git -C "${wt}" stash apply "${stash}" >/dev/null 2>&1 + then + error "the swept work conflicts in ${wt}. It is kept in the stash: git stash apply ${stash}" + return 1 + fi + slot=$(git -C "${root}" stash list --format='%gd %H' | awk -v h="${stash}" '$2 == h { print $1; exit }') + [[ -n "${slot}" ]] && git -C "${root}" stash drop "${slot}" >/dev/null + ok "Swept the work into ${wt}, uncommitted; commit it there with a real message." + [[ "$(git -C "${root}" rev-parse --abbrev-ref HEAD)" == "${default_branch}" ]] || return 0 + git -C "${root}" merge --ff-only "origin/${default_branch}" >/dev/null 2>&1 \ + || warn "${default_branch} could not fast-forward to origin; it is left as it was." +} + +# issue:pull-request <branch> <default-branch> <title> prints the number of the branch's open pull request, +# opening a draft if there is none. +issue:pull-request() { + local branch=$1 default_branch=$2 title=$3 pr + pr=$(forge:pr-for-branch "${branch}") + if [[ -z "${pr}" ]] + then + step "Opening a draft change" + pr=$(forge:pr-create "${branch}" "${default_branch}" "${title}") || return 1 + fi + printf '%s\n' "${pr}" +} + +# issue:link-in-body <change> <reference>… adds a `Closes <reference>` line to the pull request's body for each +# reference the body does not already close, fix, or resolve. The references are the secondary issues, and a +# primary issue the forge cannot link by itself. +issue:link-in-body() { + local pr=$1 ref escaped body changed=false + shift + body=$(forge:pr-body "${pr}") + for ref in "$@" + do + escaped=$(ere:quote "${ref}") + if ! grep --quiet --ignore-case --extended-regexp \ + "(close[sd]?|fix(e[sd])?|resolve[sd]?):? ${escaped}([^0-9]|\$)" <<<"${body}" + then + body="${body}"$'\n'"Closes ${ref}" + changed=true + fi + done + if [[ "${changed}" == true ]] + then + step "Linking the issues in the change's body" + forge:pr-set-body "${pr}" "${body}" + fi +} + +# issue:start [<reference>…] does what the file header describes. When the require_signing setting is true, it +# fails before writing anything if git has no signing key configured. +issue:start() { + local n primary title default_branch main_root desk desk_branch branch wt stash="" pr created sigil noun + local -a refs=() in_body=() + forge:require || return 1 + if [[ "$(release:setting require_signing)" == true ]] + then + guard:require signing-key || return 1 + fi + if (( $# == 0 )) + then + if [[ ! -t 0 ]] + then + error "usage: kit issue <reference> [<reference>…]" + return 2 + fi + created=$(issue:create) || return + set -- "${created}" + fi + for n in "$@" + do + n=$(tracker:normal "${n}") || return 2 + refs+=("${n}") + done + set -- "${refs[@]}" + primary=$1 + tracker:is-forge "${primary}" || in_body+=("${primary}") + in_body+=("${@:2}") + + git fetch --quiet --prune origin + default_branch=$(git:default-branch) + main_root=$(git:main-root) + desk=$(git rev-parse --show-toplevel) + desk_branch=$(git -C "${desk}" rev-parse --abbrev-ref HEAD) + title=$(tracker:of "${primary}" title) || return 1 + issue:assign "$@" || return + section "Starting ${primary}: ${title}" + + branch=$(issue:branch "${primary}" "${default_branch}" "${title}") || return + if [[ "${desk}" == "${main_root}" ]] && ! git:clean "${desk}" + then + step "Sweeping the uncommitted work on ${desk_branch} into the worktree" + stash=$(issue:sweep-push "${desk}" "kit issue ${primary} → ${branch}") || return + fi + wt=$(issue:worktree "${main_root}" "${branch}") || return + git -C "${main_root}" check-ignore --quiet worktrees 2>/dev/null \ + || warn "worktrees/ is not ignored; add /worktrees/ to .gitignore so a worktree never appears in a commit" + issue:start-commit "${wt}" "${default_branch}" "${primary}" "${title}" + git -C "${wt}" push --quiet --set-upstream origin "${branch}" + if [[ -n "${stash}" ]] + then + issue:sweep-apply "${desk}" "${wt}" "${stash}" "${default_branch}" || return + fi + pr=$(issue:pull-request "${branch}" "${default_branch}" "${title}") || return + (( ${#in_body[@]} > 0 )) && issue:link-in-body "${pr}" "${in_body[@]}" + + IFS=$'\t' read -r sigil noun < <(forge:noun) + ok "Draft ${noun} ${sigil}${pr} on ${branch}, primary ${primary}${2:+, secondaries: ${*:2}}" + ok "Worktree ready: ${wt}" + step "cd '${wt}' # do all work there; this checkout stays on ${desk_branch}" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit b/bin/kit new file mode 100755 index 0000000..5cee9ca --- /dev/null +++ b/bin/kit @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# kit - the kit's command: runs the verb script bin/kit-<verb>.sh (executed entrypoint) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit <verb> runs bin/kit-<verb>.sh with the remaining arguments. Before it does, it: +# +# 1. finds the release: the directory above bin/, following any symlink to this file (Homebrew links it into +# its own bin) +# 2. exports the release directory as kit_root +# 3. exports the directory the command was typed in as kit_invoked_in, for verbs that take relative paths +# 4. changes to the repository root, so every verb reads and writes the same paths wherever it was typed +# +# Each verb script does everything else, including printing its own usage. +# +# see: .kit/1st.md + +set -o errexit -o nounset -o pipefail + +if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )) +then + echo "kit needs bash 4.4 or later; this is ${BASH_VERSION}" >&2 + exit 1 +fi + +# readlink -f needs macOS 12.3 or later. Older macOS has no GNU-compatible readlink. +kit_root=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +export kit_root +kit_invoked_in=$(pwd -P) +export kit_invoked_in +if repository=$(git rev-parse --show-toplevel 2>/dev/null) +then + cd "${repository}" +fi + +verb=${1:-help} +case "${verb}" in + (help | -h | --help) + cat <<'USAGE' +usage: kit <verb> [<argument>…] + + install [<module>…] activate the default modules and the named ones, place their files, write .kit/manifest + update after the pin is bumped: replace unchanged files, three-way merge changed ones + check fail when a delivered file differs from the pinned release + module <list|add|remove> [<module>…] + list, add, or remove the modules this repository uses + context <path>… print the rules that govern each path, and log the request + new file <path> [--rule=<rule>] [--variant=<variant>] [--<hole>=<value>]… + create a file from the template of the rule that governs it; <path> is from the root + generate [--check] [<path>…] + rewrite every kit:generate region with its command's output; --check fails if one is stale + region [--home] [<path>] + print the content the active modules deliver as the region at a path + ci run the delivered CI job's checks anywhere (gate, check, linked, policy); report every failure + policy [--input] refuse a branch, commit subject, or change title the repository's policy does not allow + gate run the repository's gate (the gate setting, else the detected one); fail when there is none + linked [<number>] fail when the pull request is not linked to an open issue of this repository + toolchain <list|install|outdated|update|path|run> … + manage the tools the gate runs, through mise, brew, or docker + issue [<reference>…] start or resume an issue (#7, AB#4521): linked branch, worktree, start commit, draft change + rebase [<path>] rebase a worktree's branch onto the default branch and push it; never the desk (root checkout) + tidy [--dry-run] remove worktrees whose pull request is merged or closed, and gone branches + status [--no-fetch] [--json] [--mine | --all | --owner=<login>…] + show the desk, the worktrees, and the open pull requests: everyone's, yours, or the named + authors' + lint [--all-files] [<path>…] + run each rule's linter over the paths it governs: staged, named, or every tracked one + release <verify|pack|publish|retire> … + the kit's own release: verify a tag, pack a checkout, publish it, retire pre-releases + version print this release's version + +Run kit <verb> --help for a verb's usage. +USAGE + ;; + (*) + if [[ ! -x "${kit_root}/bin/kit-${verb}.sh" ]] + then + echo "kit: unknown verb '${verb}'; run: kit help" >&2 + exit 2 + fi + shift + exec "${kit_root}/bin/kit-${verb}.sh" "$@" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-check.sh b/bin/kit-check.sh new file mode 100755 index 0000000..df7fb52 --- /dev/null +++ b/bin/kit-check.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# kit-check - fail when a delivered file differs from the pinned release (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit check takes no arguments. It compares the repository with the release pinned in .kit/manifest, and reports +# a finding when: +# +# - .kit/manifest records a kit version other than the pinned release +# - a delivered file is missing, or differs from the release's copy +# - a merged JSON file is missing an entry the kit writes, or jq is not on PATH to read it +# - a file that should contain a kit region has none +# - a file the repository owns is missing a line a module expects in it +# - a generated region differs from what its command makes +# +# Files delivered to the home directory are checked only when ~/.kit/manifest exists, that is, on a machine where +# kit install ran. On a machine without it, such as a CI runner, kit check prints a note and skips them. +# +# Exits 0 when there are no findings, and 1 otherwise. +# +# see: bin/release.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release generate + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit check" >&2 + exit 0 +fi + +release:require-manifest +readarray -t modules < <(release:manifest-modules) +version=$(release:manifest-version) +release:render-all "${kit_root}" "${modules[@]}" +findings=0 +if [[ "${version}" != "$(release:version)" ]] +then + error ".kit/manifest records kit ${version}, and the pinned release is $(release:version); run: kit update" + findings=$(( findings + 1 )) +fi +for base in $(release:bases) +do + delivered=$(release:delivered "${kit_root}" "${base}" "${modules[@]}") + [[ -n "${delivered}" ]] || continue + if [[ "${base}" != repo && ! -f "$(release:manifest "${base}")" ]] + then + info "${base}: nothing is installed under $(release:base-root "${base}") on this machine; not checked" + continue + fi + while IFS=$'\t' read -r path src + do + dest=$(release:dest "${base}" "${path}") + kind=$(release:kind "${src}" "${dest}") + if [[ ! -f "${dest}" ]] + then + error "${dest} is missing" + findings=$(( findings + 1 )) + elif [[ "${kind}" == merge ]] && ! command -v jq >/dev/null 2>&1 + then + error "run: mise install (jq is not on PATH, so ${dest} could not be read)" + findings=$(( findings + 1 )) + elif [[ "${kind}" == region ]] + then + # Only the directive is checked here. Its content is checked below, with every other generated region. + if ! release:installed "${src}" "${dest}" >/dev/null + then + error "${dest} has no kit region; run: kit update" + findings=$(( findings + 1 )) + fi + elif ! release:holds "${src}" "${dest}" + then + case "${kind}" in + (merge) error "${dest} is missing an entry the kit writes; run: kit update" ;; + (*) error "${dest} differs from the pinned release" ;; + esac + findings=$(( findings + 1 )) + fi + done <<<"${delivered}" +done +# Report each line a module expects in a file the repository owns that is missing. The kit never writes these lines. +while IFS= read -r unmet +do + [[ -n "${unmet}" ]] || continue + error "${unmet}" + findings=$(( findings + 1 )) +done < <(release:unmet "${kit_root}" "${modules[@]}") +# Check that every generated region, the kit's and the repository's own, matches what its command makes. +generate:run --check || findings=$(( findings + 1 )) +if (( findings > 0 )) +then + error "${findings} finding(s). A delivered file changes by a new release and kit update, never in place;" \ + "a generated region by kit generate." + exit 1 +fi +ok "kit ${version}, modules ${modules[*]:-none}: every delivered file matches the pinned release." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-ci.sh b/bin/kit-ci.sh new file mode 100755 index 0000000..6337d89 --- /dev/null +++ b/bin/kit-ci.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# kit-ci - run the delivered CI job's four checks on any machine (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit ci takes no arguments. It runs these checks in order: +# +# 1. kit gate: the repository's gate (its own CI command) passes +# 2. kit check: every delivered file matches the pinned release +# 3. kit linked: the pull request is linked to an open issue +# 4. kit policy: the branch, commit subjects, and pull request title match the policy +# +# Every check runs even when an earlier one fails, so one run reports every finding. +# +# Exits 0 when all four pass. Otherwise the exit status is the number of checks that failed. +# +# see: bin/kit-gate.sh, bin/kit-check.sh, bin/kit-linked.sh, bin/kit-policy.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit ci" >&2 + exit 0 +fi + +refused=0 +for verb in gate check linked policy +do + section "kit ${verb}" + "${kit_root}/bin/kit-${verb}.sh" || refused=$(( refused + 1 )) +done +if (( refused > 0 )) +then + error "${refused} of 4 checks refused; see above" + exit "${refused}" +fi +ok "kit ci: the gate, the kit's check, the issue link, and the policy all passed." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-context.sh b/bin/kit-context.sh new file mode 100755 index 0000000..605727a --- /dev/null +++ b/bin/kit-context.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# kit-context - print the rules that govern a file, and log the request (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit context <path>… [--json] +# +# Prints the path of each rule that governs the given paths, once each. With --json, it prints one JSON object per +# path with that path's rules. Each request is logged to kit/context.log in git's common directory. +# +# kit context --claude +# +# Runs as Claude Code's PreToolUse hook. It reads the hook's input on stdin, and returns the rules that govern the +# file the tool is about to write, as additional context. +# +# Exits 2 on a usage error. +# +# see: bin/context.sh, docs/context.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use context + +case "${1:-}" in + (--help) + echo "usage: kit context <path>… [--json] | kit context --claude" >&2 + exit 0 + ;; + (--claude) + context:claude + ;; + (*) + context:render "$@" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-gate.sh b/bin/kit-gate.sh new file mode 100755 index 0000000..03a4c35 --- /dev/null +++ b/bin/kit-gate.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# kit-gate - run the repository's gate, and refuse when there is none (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit gate takes no arguments. It runs the repository's gate, the repository's own CI command. The gate is the +# first of: +# +# 1. the `gate` setting in .kit/settings.yml +# 2. `just ci`, when the root justfile has a ci recipe +# 3. `make ci`, when the Makefile has a ci target +# 4. `npm run ci`, when package.json has a ci script +# +# The gate runs with the declared toolchains' tools, inside the container when the toolchain is docker. +# +# kit gate fails, and prints the remedy, when the repository has no gate or the root justfile cannot be read. A +# gate that cannot run never counts as a pass. On a CI runner, each outcome is also written to the step summary. +# +# Exits 0 when the gate passes, and 1 otherwise. +# +# see: bin/toolchain.sh, modules/ci-github/templates/repo/.github/workflows/kit-ci.yml + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release toolchain + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit gate" >&2 + exit 0 +fi + +# detect-gate prints the command of the first gate found, or nothing. It fails when the root justfile cannot be read. +detect-gate() { + local recipes + if [[ -f justfile || -f Justfile || -f .justfile ]] + then + if ! recipes=$(just --summary 2>&1) + then + error "the root justfile could not be read: ${recipes}" + summary "### ❌ The root justfile could not be read" "" "${recipes}" + return 1 + fi + tr ' ' '\n' <<<"${recipes}" | grep --quiet --line-regexp ci && printf 'just ci\n' && return 0 + fi + if [[ -f Makefile ]] && grep --quiet '^ci:' Makefile + then + printf 'make ci\n' + return 0 + fi + if [[ -f package.json ]] && jq --exit-status '.scripts.ci' package.json >/dev/null 2>&1 + then + printf 'npm run ci\n' + return 0 + fi + return 0 +} + +gate=$(release:setting gate) +[[ -n "${gate}" ]] || gate=$(detect-gate) || exit 1 +if [[ -z "${gate}" ]] +then + error "set gate in .kit/settings.yml, or give the root justfile a ci recipe; there is no gate to run" + # shellcheck disable=SC2016 # markdown code spans, not expansions + summary "### ❌ The gate cannot run: the repository has none" "" \ + 'Set `gate` in `.kit/settings.yml`, or give the root justfile a `ci` recipe.' + exit 1 +fi +step "the gate: ${gate}" +if ! toolchain:run bash -c "${gate}" +then + error "the gate failed: ${gate}" + summary "### ❌ The gate failed: \`${gate}\`" + exit 1 +fi +summary "✅ The gate passed: \`${gate}\`." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-generate.sh b/bin/kit-generate.sh new file mode 100755 index 0000000..6797929 --- /dev/null +++ b/bin/kit-generate.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# kit-generate - expand every kit:generate region to what its command makes, or check that it is (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit generate [--check] [--iterations=<n>] [<path>…] +# +# Paths are relative to the current directory. With no paths, kit generate expands every tracked file and every +# untracked file git does not ignore. +# +# - Named files are expanded once, or <n> times with --iterations=<n>. +# - With no paths, kit generate repeats until a pass changes nothing. +# - --check writes nothing, and fails naming every stale file. +# +# kit check runs kit generate --check, and the lefthook pre-commit hook runs it over the staged paths. +# +# Exits 0 on success, 2 on an unknown flag, and non-zero when a file is stale (with --check) or cannot be expanded. +# +# see: bin/generate.sh, docs/generate.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use context generate + +args=() +for arg in "$@" +do + case "${arg}" in + (--help) + echo "usage: kit generate [--check] [--iterations=<n>] [<path>…]" >&2 + exit 0 + ;; + (--check | --iterations=*) args+=("${arg}") ;; + (-*) + error "unknown flag '${arg}' (usage: kit generate [--check] [--iterations=<n>] [<path>…])" + exit 2 + ;; + (*) args+=("$(context:relative "${arg}")") + esac +done +generate:run "${args[@]}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-install.sh b/bin/kit-install.sh new file mode 100755 index 0000000..12bda08 --- /dev/null +++ b/bin/kit-install.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# kit-install - mirror the release into the repository, and into the home directory (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit install [<module>…] +# +# Sets up kit in a repository that does not have it yet: +# +# 1. Activates the default modules, the named modules, and the modules they depend on. +# 2. Places the files those modules deliver, in the repository and in the home directory. +# 3. Writes .kit/manifest, and ~/.kit/manifest when the modules deliver files to the home directory. +# +# When a different file is already at a delivered path, kit install names it and leaves it alone. It writes no +# manifest until every path is clear. A repository that already has .kit/manifest is refused: run kit update. +# +# Exits 0 on success, 2 for a module the release does not have, and 1 for any other failure. +# +# see: bin/release.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit install [<module>…]" >&2 + exit 0 +fi + +guard:require tool:jq +release:require-modules "${kit_root}" "$@" +if [[ -f .kit/manifest ]] +then + error "run: kit update (this repository already carries kit $(release:manifest-version))" + exit 1 +fi +readarray -t defaults < <(release:default-modules "${kit_root}") +readarray -t modules < <(release:resolve "${kit_root}" "${defaults[@]}" "$@") +release:render-all "${kit_root}" "${modules[@]}" +section "kit install $(release:version), modules: ${modules[*]}" +refused=0 +for base in $(release:bases) +do + release:place-all "${kit_root}" "${base}" "${modules[@]}" || refused=$(( refused + $? )) +done +if (( refused > 0 )) +then + error "${refused} path(s) already hold a different file; move each aside, then run: kit install" + exit 1 +fi +release:write-manifests "${modules[@]}" +release:finish "${modules[@]}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-issue.sh b/bin/kit-issue.sh new file mode 100755 index 0000000..a3e401f --- /dev/null +++ b/bin/kit-issue.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# kit-issue - start or resume an issue (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit issue [<reference>…] +# +# Starts work on an issue, or resumes it. kit issue makes each of these, and reuses any that already exists: +# +# - a branch linked to the issue +# - a worktree for the branch +# - a start commit +# - a draft pull request (a merge request on GitLab) +# +# A reference names the issue and its tracker: +# +# - `7` or `#7` for an issue on the forge (the service that hosts the repository, GitHub or GitLab) +# - `AB#4521` for an Azure DevOps work item, when that tracker is declared in .kit/settings.yml +# +# With no reference, at a terminal, kit issue asks for a title and creates the issue. +# +# see: bin/issue.sh, bin/forge.sh, bin/tracker.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use issue + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit issue [<reference>…] (none, at a terminal: ask for a title and create the issue)" >&2 + exit 0 +fi + +issue:start "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-linked.sh b/bin/kit-linked.sh new file mode 100755 index 0000000..f8614a6 --- /dev/null +++ b/bin/kit-linked.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# kit-linked - refuse a change not linked to an open issue of this repository (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit linked [<number>] +# +# Fails when a pull request is not linked to an open issue of this repository. kit ci runs it in the delivered CI +# job, and anyone can run it on any pull request. On GitLab it checks a merge request, and its messages say so. +# +# The pull request is <number>, or else the one this checkout is working on. A linked issue counts when it is +# open and belongs to this repository. A closed issue, or one in another repository, is reported and does not +# count. On a CI runner, the remedy is also written to the step summary. +# +# Exits 0 when at least one linked issue counts, 1 when none does, and 2 when there is no pull request to check. +# +# see: bin/forge.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use forge tracker + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit linked [<number>]" >&2 + exit 0 +fi + +number=${1:-} +[[ -n "${number}" ]] || number=$(forge:pr-here) +if [[ ! "${number}" =~ ^[0-9]+$ ]] +then + error "name the change: kit linked <number> (none was given, and this checkout is working on none)" + exit 2 +fi +IFS=$'\t' read -r sigil noun < <(forge:noun) || exit 1 +change="${noun} ${sigil}${number}" +repo=$(forge:repo) || exit 1 +url=$(forge:pr-url "${number}") || exit 1 +remedy=$(printf '%s\n' \ + "Link an open issue of this repository to [the ${noun}](${url}):" "" \ + "1. add a line \`Closes <reference>\` to its description for each open issue it resolves, #7 or AB#4521" \ + "2. re-run the check: linking fires no event, so it does not re-run on its own" "" \ + "\`kit issue <N>\` makes this link when it starts the work.") +refs=$(forge:pr-closing-issues "${number}") || exit 1 +# The forge does not link issues of other trackers, such as Azure DevOps. A closing keyword in the pull request's +# body links them: `Closes AB#4521`. +others=$(tracker:body-refs "$(forge:pr-body "${number}")") +if [[ -z "${refs}${others}" ]] +then + error "${change} is not linked to any issue" + summary "### ❌ The ${change} is not linked to any issue" "" "${remedy}" + exit 1 +fi +open=0 +while read -r n state issue_repo +do + [[ -n "${n}" ]] || continue + if [[ "${issue_repo}" != "${repo}" ]] + then + warn "skipping ${issue_repo}#${n}: it belongs to another repository" + elif [[ "${state}" != OPEN ]] + then + warn "#${n} is ${state}; merging will not change it" + else + open=$(( open + 1 )) + fi +done <<<"${refs}" +while read -r ref +do + [[ -n "${ref}" ]] || continue + state=$(tracker:of "${ref}" state) || continue + if [[ "${state}" != OPEN ]] + then + warn "${ref} is ${state}; merging will not change it" + else + open=$(( open + 1 )) + fi +done <<<"${others}" +if (( open == 0 )) +then + error "every issue ${change} is linked to is closed or belongs to another repository" + summary "### ❌ Every linked issue is closed or belongs to another repository" "" "${remedy}" + exit 1 +fi +closes=$({ + [[ -z "${refs}" ]] || cut -d ' ' -f 1 <<<"${refs}" | sed 's/^/#/' + [[ -z "${others}" ]] || printf '%s\n' "${others}" +} | paste -s -d ' ' -) +ok "${change} is linked to ${closes}" +summary "✅ Linked to ${closes}." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-lint.sh b/bin/kit-lint.sh new file mode 100755 index 0000000..6764f6a --- /dev/null +++ b/bin/kit-lint.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# kit-lint - run each rule's linter over the paths it governs (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit lint [--all-files] [<path>…] +# +# Runs each rule's checks over the files the rule governs. The files are: +# +# - the paths given, relative to the current directory +# - with no paths, the staged files +# - with no paths and --all-files, every tracked file +# +# Each file is matched to the rules that govern it, the same way kit context matches. Then, for each rule: +# +# - When the rule's frontmatter has a `lint:` command, kit lint runs it once over all the rule's files. A linter +# that is not on PATH is a failure, not a skip. +# - When the rule has templates, each of its files must have the shape of one of them (see bin/template.sh). +# +# Files under .kit/templates/ are templates, not files of their kind, and are not linted. +# +# kit lint assumes no hook manager. The lefthook module runs it from a pre-commit hook. +# +# Exits 0 when every rule passes, and 2 on an unknown flag. Otherwise the exit status is the number of rules that +# failed. +# +# see: bin/context.sh, bin/template.sh, docs/context.md, docs/templates.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use context template yaml + +all=false +paths=() +for arg in "$@" +do + case "${arg}" in + (--help) + echo "usage: kit lint [--all-files] [<path>…]" >&2 + exit 0 + ;; + (--all-files) all=true ;; + (-*) + error "unknown flag '${arg}' (usage: kit lint [--all-files] [<path>…])" + exit 2 + ;; + (*) paths+=("${arg}") + esac +done +# A named path is relative to the current directory, and is converted to a path from the repository root. git's +# lists are already root-relative. They are read NUL-separated, so a name with a space or a non-ASCII character +# survives. +for i in "${!paths[@]}" +do + paths[i]=$(context:relative "${paths[i]}") +done +if (( ${#paths[@]} == 0 )) +then + if [[ "${all}" == true ]] + then + readarray -d '' -t paths < <(git ls-files -z) + else + readarray -d '' -t paths < <(git diff --cached --name-only --diff-filter=ACMR -z) + fi +fi +if (( ${#paths[@]} == 0 )) +then + ok "nothing to lint" + exit 0 +fi + +# The paths each rule governs, newline-separated, keyed by the rule file. +declare -A governed=() +for path in "${paths[@]}" +do + [[ -f "${path}" && ! "${path}" =~ (^|/)\.kit/templates/ ]] || continue + while IFS= read -r rule + do + [[ -n "${rule}" ]] || continue + governed[${rule}]+="${path}"$'\n' + done < <(context:rules "${path}") +done + +refused=0 +for rule in $(printf '%s\n' "${!governed[@]}" | LC_ALL=C sort) +do + if [[ -n "$(template:variants "${rule}")" ]] + then + held=() + while IFS= read -r path + do + [[ -n "${path}" ]] && ! template:exempt "${path}" && held+=("${path}") + done < <(printf '%s' "${governed[${rule}]}") + step "${rule}: the shape of its templates over ${#held[@]} path(s)" + if (( ${#held[@]} > 0 )) && ! template:check "${rule}" "${held[@]}" + then + refused=$(( refused + 1 )) + fi + fi + lint=$(yaml:value "${rule}" lint) + [[ -n "${lint}" ]] || continue + read -r -a command <<<"${lint}" + readarray -t targets < <(printf '%s' "${governed[${rule}]}") + step "${rule}: ${lint} over ${#targets[@]} path(s)" + if ! guard:require "tool:${command[0]}" + then + refused=$(( refused + 1 )) + continue + fi + "${command[@]}" "${targets[@]}" || refused=$(( refused + 1 )) +done +if (( refused > 0 )) +then + error "${refused} rule(s) refused; see above" + exit "${refused}" +fi +ok "every governed path passes its rule's linter" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-module.sh b/bin/kit-module.sh new file mode 100755 index 0000000..f532a68 --- /dev/null +++ b/bin/kit-module.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# kit-module - list, activate, or deactivate the modules this repository takes (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit module list | add <module>… | remove <module>… +# +# list prints every module in reach, with a ✅ beside the active ones, its origin (release or +# repository), and its description. This is the default. +# add <module>… activates the modules and the modules they depend on, places the files they deliver, +# and rewrites the manifests. When a different file is already at a delivered path, it is +# named and left alone, and no manifest is written. +# remove <module>… takes out what the modules delivered (files, the kit's JSON entries, and the kit's +# regions) and rewrites the manifests. A module that an active module depends on is refused. +# +# Exits 0 on success, 2 on a usage error or a module the release does not have, and 1 for any other failure. +# +# see: bin/release.sh, docs/modules.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +usage() { echo "usage: kit module list | kit module add <module>… | kit module remove <module>…" >&2; } + +verb=${1:-list} +shift || true +case "${verb}" in + (--help) usage; exit 0 ;; + (list | add | remove) ;; + (*) usage; exit 2 +esac +release:require-manifest +readarray -t active < <(release:manifest-modules) + +case "${verb}" in + (list) + for module in $(release:modules) + do + if release:among "${module}" "${active[@]}" + then mark='✅' + else mark=' ' + fi + printf '%s %-10s %-10s %s\n' "${mark}" "${module}" "$(release:origin "${kit_root}" "${module}")" \ + "$(release:description "${kit_root}" "${module}")" + done + ;; + (add) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq + release:require-modules "${kit_root}" "$@" + readarray -t wanted < <(release:resolve "${kit_root}" "${active[@]}" "$@") + release:render-all "${kit_root}" "${wanted[@]}" + section "kit module add $*" + refused=0 + for base in $(release:bases) + do + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + release:place-new "$(release:source-of "${kit_root}" "${base}" "${path}" "${wanted[@]}")" \ + "$(release:dest "${base}" "${path}")" "placed" || refused=$(( refused + 1 )) + done < <(release:paths-beyond "${kit_root}" "${base}" "${active[*]}" "${wanted[*]}") + done + if (( refused > 0 )) + then + error "${refused} path(s) already hold a different file; move each aside, then run: kit module add $*" + exit 1 + fi + release:write-manifests "${wanted[@]}" + ok "modules: ${wanted[*]}" + release:say-unmet "${wanted[@]}" + ;; + (remove) + (( $# > 0 )) || { usage; exit 2; } + guard:require tool:jq + release:require-modules "${kit_root}" "$@" + remaining=("${active[@]}") + for module in "$@" + do + release:among "${module}" "${active[@]}" || step "${module}: not active" + readarray -t remaining < <(release:without "${module}" "${remaining[@]}") + done + # Dependents are checked against the modules that remain, so the order of the named modules does not matter. + for module in "$@" + do + readarray -t dependents < <(release:dependents "${kit_root}" "${module}" "${remaining[@]}") + if (( ${#dependents[@]} > 0 )) + then + error "${module} cannot be removed while ${dependents[*]} depend(s) on it" + exit 1 + fi + done + release:render-all "${kit_root}" "${active[@]}" + section "kit module remove $*" + for base in $(release:bases) + do + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + release:unplace "$(release:source-of "${kit_root}" "${base}" "${path}" "${active[@]}")" \ + "$(release:dest "${base}" "${path}")" + done < <(release:paths-beyond "${kit_root}" "${base}" "${remaining[*]}" "${active[*]}") + done + release:write-manifests "${remaining[@]}" + ok "modules: ${remaining[*]:-none}" +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-new.sh b/bin/kit-new.sh new file mode 100755 index 0000000..dfd46cc --- /dev/null +++ b/bin/kit-new.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# kit-new - make a new file from the template of the rule that governs it (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit new file <path> [--rule=<rule>] [--variant=<variant>] [--<hole>=<value>]… +# +# Makes a new file from a rule's template. Then it prints the rules to read before writing the rest of the file, +# as kit context does. +# +# <path> the new file, relative to the repository root wherever the command runs. An absolute +# path inside the repository is accepted. A path outside it is refused. +# --rule=<rule> the rule whose template to use. Default: the most specific rule that governs the path +# and has templates. A script with no extension needs this flag. +# --variant=<variant> the template to use. Default: the first variant the rule lists that the path allows. +# --<hole>=<value> the value of a hole («name») within a line of the template. The holes path, name, and +# stem default to values taken from the path. Every other hole needs its flag. +# +# kit new never overwrites a file: an existing path is refused. +# +# Exits 0 when the file is made, 1 when the path exists or the template is missing, and 2 on a usage error. +# +# see: bin/template.sh, docs/templates.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use context template + +usage="usage: kit new file <path> [--rule=<rule>] [--variant=<variant>] [--<hole>=<value>]…" +if [[ "${1:-}" == --help ]] +then + echo "${usage}" >&2 + exit 0 +fi +if [[ "${1:-}" != file ]] +then + error "kit new makes a file: ${usage}" + exit 2 +fi +shift + +path='' rule_name='' variant='' +declare -A values=() +for arg in "$@" +do + case "${arg}" in + (--rule=?*) rule_name=${arg#--rule=} ;; + (--variant=?*) variant=${arg#--variant=} ;; + (--[a-z_]*=*) + key=${arg%%=*} + key=${key#--} + values[${key}]=${arg#*=} + ;; + (-*) + error "unknown flag '${arg}' (${usage})" + exit 2 + ;; + (*) + if [[ -n "${path}" ]] + then + error "one path at a time (${usage})" + exit 2 + fi + path=${arg} + esac +done +if [[ -z "${path}" ]] +then + error "${usage}" + exit 2 +fi + +# An absolute path inside the repository is made relative to the root. A path outside the repository is refused. +root=$(pwd -P) +if [[ "${path}" == /* ]] +then + path=$(context:relative "${path}") + if [[ "${path}" == /* ]] + then + error "${path} is outside the repository at ${root}" + exit 2 + fi +fi +path=${path#./} +if [[ "${path}" == ../* || "${path}" == */../* ]] +then + error "${path} climbs out of the repository; name it from the root" + exit 2 +fi +if [[ -e "${path}" ]] +then + error "${path} is there already; kit new makes a file, it never overwrites one" + exit 1 +fi + +if [[ -n "${rule_name}" ]] +then + rule=".kit/rules/${rule_name}.md" + if [[ ! -f "${rule}" ]] + then + error "there is no rule ${rule_name}; the rules are: $(find .kit/rules -maxdepth 1 -name '*.md' \ + | sed 's@^.*/@@; s/\.md$//' | LC_ALL=C sort | paste -s -d ' ' -)" + exit 2 + fi +else + rule=$(template:rules "${path}" | head -n 1) + if [[ -z "${rule}" ]] + then + error "no rule with a template governs ${path}; name one with --rule=<rule>, or write the file by hand" + exit 2 + fi +fi + +chosen='' executable='' +while IFS=$'\x1f' read -r name _ exec_flag +do + [[ -n "${name}" ]] || continue + if [[ -z "${variant}" || "${name}" == "${variant}" ]] + then + chosen=${name} + executable=${exec_flag} + break + fi +done < <(if [[ -n "${variant}" ]]; then template:variants "${rule}"; else template:applies "${rule}" "${path}"; fi) +if [[ -z "${chosen}" ]] +then + error "the $(template:rule-name "${rule}") rule has no template ${variant:-for ${path}}; its templates are:" \ + "$(template:variants "${rule}" | cut -d $'\x1f' -f 1 | paste -s -d ' ' -)" + exit 2 +fi +template=$(template:file "${rule}" "${chosen}") +if [[ ! -f "${template}" ]] +then + error "${rule} lists the template ${chosen}, and ${template} is not here; run: kit update" + exit 1 +fi + +name=${path##*/} +values[path]=${values[path]:-${path}} +values[name]=${values[name]:-${name}} +values[stem]=${values[stem]:-${name%.*}} +missing=() +while IFS= read -r hole +do + [[ -n "${hole}" && -z "${values[${hole}]+x}" ]] && missing+=("--${hole}=<…>") +done < <(template:holes "${template}") +if (( ${#missing[@]} > 0 )) +then + error "the ${chosen} template of the $(template:rule-name "${rule}") rule needs ${missing[*]}" + exit 2 +fi + +json='{}' +for key in "${!values[@]}" +do + json=$(jq --compact-output --arg k "${key}" --arg v "${values[${key}]}" '.[$k] = $v' <<<"${json}") +done +mkdir -p "$(dirname "${path}")" +template:render "${template}" "${json}" > "${path}" +[[ "${executable}" == true ]] && chmod +x "${path}" +context:log "${path}" +ok "made ${path} from ${template}; read the rules below before writing the rest" +context:rules "${path}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-policy.sh b/bin/kit-policy.sh new file mode 100755 index 0000000..0d204bd --- /dev/null +++ b/bin/kit-policy.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# kit-policy - refuse a branch, a commit subject, or a change title the repository's policy does not allow (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit policy [--input] +# +# Fails when a name breaks the repository's policy. kit policy collects these facts once: +# +# - the branch name +# - the subject of each commit since the branch forked from the default branch +# - the open pull request's title +# +# The policy engine then reports every refusal. The engine is patterns (the default) or Open Policy Agent. With +# --input, kit policy prints the facts as JSON instead: the document a rule is written against. +# +# kit ci runs kit policy alongside the gate. On a CI runner, refusals are also written to the step summary. +# +# Exits 0 when the policy allows every name, 1 on any refusal, and 2 on a usage error. +# +# see: bin/policy.sh, docs/policy.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use policy + +case "${1:-}" in + (--help) + echo "usage: kit policy [--input]" >&2 + exit 0 + ;; + (--input) + policy:input + exit 0 + ;; + ('') ;; + (*) + echo "usage: kit policy [--input]" >&2 + exit 2 +esac + +input=$(mktemp) +trap 'rm -f "${input}"' EXIT +policy:input > "${input}" +engine=$(policy:engine) +refusals=$(policy:deny "${input}") || exit 1 +if [[ -n "${refusals}" ]] +then + while IFS= read -r refusal + do + error "${refusal}" + done <<<"${refusals}" + count=$(wc -l <<<"${refusals}" | tr -d ' ') + error "${count} refusal(s) by the ${engine} policy; .kit/settings.yml holds the patterns, docs/policy.md says how" + summary "### ❌ ${count} refusal(s) by the policy" "" "- ${refusals//$'\n'/$'\n'- }" + exit 1 +fi +subjects=$(jq --raw-output '[if .branch then "the branch" else empty end, + "\(.commits | length) commit(s)", if .change then "the change title" else empty end] | join(", ")' "${input}") +ok "the ${engine} policy allows ${subjects}" +summary "✅ The policy allows ${subjects}." + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-rebase.sh b/bin/kit-rebase.sh new file mode 100755 index 0000000..907417b --- /dev/null +++ b/bin/kit-rebase.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# kit-rebase - rebase a worktree's branch onto the default branch and push it (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit rebase [<path>] +# +# Rebases the branch checked out at <path> (default: the current checkout) onto the default branch at origin, and +# pushes it with --force-with-lease. kit rebase refuses: +# +# - the root checkout (the desk), which belongs to the human, whatever branch it has +# - the default branch, in any checkout +# - a worktree with uncommitted changes +# +# On a conflict, kit rebase aborts and leaves the worktree as it was. +# +# see: bin/git.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use git + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit rebase [<path>] (no path: the checkout this runs in)" >&2 + exit 0 +fi + +guard:require tool:git +git:rebase "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-region.sh b/bin/kit-region.sh new file mode 100755 index 0000000..b92746f --- /dev/null +++ b/bin/kit-region.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# kit-region - print the content the active modules deliver as the region at a path (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit region [--home] [<path>] +# +# Prints the content an active module delivers as the region at <path>. Every region the kit delivers is filled +# this way: kit install writes a bare directive, such as `kit:generate AGENTS.md`, and kit generate fills it by +# running `kit region` with the region's name in KIT_GENERATE_REGION. +# +# <path> the region's path, relative to the repository root. Default: KIT_GENERATE_REGION. +# --home makes the path relative to the home directory instead. +# +# Only one active module delivers a region at a given path, so kit region prints that module's +# regions/<base>/<path>. +# +# Exits 0 when it prints the region, 1 when no active module delivers a region at <path>, and 2 on a usage error. +# +# see: bin/generate.sh, bin/release.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +base=repo +path='' +for arg in "$@" +do + case "${arg}" in + (--help) + echo "usage: kit region [--home] [<path>] (the path defaults to KIT_GENERATE_REGION)" >&2 + exit 0 + ;; + (--home) base=home ;; + (-*) + error "unknown flag '${arg}' (usage: kit region [--home] [<path>])" + exit 2 + ;; + (*) path=${arg} + esac +done +path=${path:-${KIT_GENERATE_REGION:-}} +if [[ -z "${path}" ]] +then + echo "usage: kit region [--home] [<path>] (the path defaults to KIT_GENERATE_REGION)" >&2 + exit 2 +fi +release:require-manifest +readarray -t modules < <(release:manifest-modules) +release:render-all "${kit_root}" "${modules[@]}" +src=$(release:source-of "${kit_root}" "${base}" "${path}" "${modules[@]}") +if [[ -z "${src}" ]] || [[ "$(release:kind "${src}" "${path}")" != region ]] +then + error "no active module delivers a region at ${path} (modules: ${modules[*]}); see .kit/manifest" + exit 1 +fi +cat "${src}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-release.sh b/bin/kit-release.sh new file mode 100755 index 0000000..ec85127 --- /dev/null +++ b/bin/kit-release.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# kit-release - verify a release tag, pack a checkout as a release, publish it, and retire pre-releases (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit release verify <tag> | pack <tag> [<directory>] | publish <tag> | retire [<branch>] +# +# Releases the kit itself. The release workflows and the root checkout (the desk) run the same subcommands. +# +# verify <tag> checks the tag. A release tag is release/<major>.<minor>.<patch>, on the default +# branch. A pre-release tag adds -<suffix>, and is on a commit off the default +# branch. Any other tag is refused, with the reason. +# pack <tag> [<directory>] writes kit-<version>.tar.gz and its .sha256 to <directory> (default: the current +# directory). The tarball holds one directory, kit-<version>/, with bin/, modules/, +# and a version file. +# publish <tag> packs, then creates a GitHub release with both files. A tag with a suffix is marked +# pre-release. Only a runner publishes a release, from the release workflow that a +# pushed tag starts. A desk may publish a pre-release. +# retire [<branch>] deletes every pre-release whose commit no remaining branch reaches. It runs after +# <branch> is deleted. Default: the branch in the runner's delete event. A pre-release +# merged by a merge commit stays reachable from the default branch, so it is kept. A +# squash merge leaves it unreachable, so it is deleted. +# +# A release is permanent and never changes. Only a pre-release is ever retired. +# +# see: .github/workflows/release.yml, .github/workflows/retire-pre-releases.yml + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use git release + +usage() { echo "usage: kit release verify <tag> | pack <tag> [<directory>] | publish <tag> | retire [<branch>]" >&2; } + +# refuse-tag <message> prints the message as an error and to the step summary, then exits 1. +refuse-tag() { + error "$1" + summary "### ❌ $1" + exit 1 +} + +# tag-version <tag> prints the tag's version, the part after release/. It refuses a tag that is not a release tag. +tag-version() { + local version=${1#release/} + if [[ "$1" != release/* || ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] + then + refuse-tag "$1 is not a release tag: release/<major>.<minor>.<patch>, with -<suffix> for a pre-release" + fi + printf '%s\n' "${version}" +} + +verb=${1:-} +case "${verb}" in + (--help | '') usage; exit 0 ;; + (verify | pack | publish | retire) ;; + (*) usage; exit 2 +esac +shift + +case "${verb}" in + (verify) + (( $# == 1 )) || { usage; exit 2; } + tag=$1 + version=$(tag-version "${tag}") + default_branch=$(git:default-branch) + on_default=false + git merge-base --is-ancestor "${tag}" "origin/${default_branch}" 2>/dev/null && on_default=true + if [[ "${version}" == *-* && "${on_default}" == true ]] + then + refuse-tag "${tag} is a pre-release tag on ${default_branch}; tag a pre-release on a commit off it" + fi + if [[ "${version}" != *-* && "${on_default}" == false ]] + then + refuse-tag "${tag} is not on ${default_branch}; a release is tagged there, a pre-release has a suffix" + fi + ok "${tag} is a version tag on the right side of ${default_branch} (on it: ${on_default})" + summary "✅ ${tag} is a version tag on the right side of ${default_branch}." + ;; + (pack) + (( $# >= 1 && $# <= 2 )) || { usage; exit 2; } + version=$(tag-version "$1") + into=${2:-.} + staging=$(mktemp -d) + # shellcheck disable=SC2064 # expanded now on purpose: the path is fixed from here on + trap "rm -rf '${staging}'" EXIT + mkdir -p "${into}" "${staging}/kit-${version}" + cp -R "${kit_root}/bin" "${kit_root}/modules" "${staging}/kit-${version}/" + printf '%s\n' "${version}" > "${staging}/kit-${version}/version" + tar --create --gzip --file "${into}/kit-${version}.tar.gz" --directory "${staging}" "kit-${version}" + printf '%s kit-%s.tar.gz\n' "$(release:digest < "${into}/kit-${version}.tar.gz")" "${version}" \ + > "${into}/kit-${version}.tar.gz.sha256" + ok "packed kit ${version}: ${into}/kit-${version}.tar.gz and its .sha256" + ;; + (publish) + (( $# == 1 )) || { usage; exit 2; } + tag=$1 + version=$(tag-version "${tag}") + if [[ "${version}" != *-* && -z "${GITHUB_ACTIONS:-}" ]] + then + refuse-tag "a release is published by the release workflow, not from a desk; push the tag: git push origin ${tag}" + fi + guard:require tool:gh + into=$(mktemp -d) + # shellcheck disable=SC2064 # expanded now on purpose: the path is fixed from here on + trap "rm -rf '${into}'" EXIT + "${kit_root}/bin/kit" release pack "${tag}" "${into}" + pin=$(printf '"github:empowerite/kit" = { version = "%s", version_prefix = "release/" }' "${version}") + notes=$(printf 'Pin it in mise.toml:\n\n [tools]\n %s\n\n' "${pin}" + printf 'Then, in the repository: kit install\n') + flags=() + [[ "${version}" == *-* ]] && flags+=(--prerelease) + gh release create "${tag}" "${into}/kit-${version}.tar.gz" "${into}/kit-${version}.tar.gz.sha256" \ + --title "kit ${version}" --notes "${notes}" "${flags[@]}" + ok "released kit ${version}" + summary "✅ Released kit ${version}." + ;; + (retire) + (( $# <= 1 )) || { usage; exit 2; } + deleted=${1:-} + [[ -n "${deleted}" || -z "${GITHUB_EVENT_PATH:-}" ]] \ + || deleted=$(jq --raw-output '.ref // empty' "${GITHUB_EVENT_PATH}") + guard:require tool:gh + git fetch --quiet --tags origin + retired=0 + while IFS= read -r tag + do + [[ -n "${tag}" ]] || continue + commit=$(git rev-parse --verify --quiet "${tag}^{commit}" || true) + if [[ -z "${commit}" ]] + then + warn "${tag} has no commit here; leaving it" + continue + fi + if git branch --remotes --contains "${commit}" | grep --quiet --invert-match 'origin/HEAD' + then + step "keeping ${tag}: a branch still reaches ${commit:0:7}" + continue + fi + gh release delete "${tag}" --cleanup-tag --yes + step "retired ${tag}: no branch reaches ${commit:0:7}${deleted:+ since ${deleted} was deleted}" + retired=$(( retired + 1 )) + done < <(gh release list --limit 1000 --json tagName,isPrerelease --jq '.[] | select(.isPrerelease) | .tagName') + ok "retired ${retired} pre-release(s)" + summary "✅ Retired ${retired} pre-release(s)." +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-status.sh b/bin/kit-status.sh new file mode 100755 index 0000000..17994d6 --- /dev/null +++ b/bin/kit-status.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# kit-status - show every worktree with its branch, pull request, and state (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit status [--no-fetch] [--json] [--mine | --all | --owner=<login>…] +# +# Shows every worktree with its branch, pull request, and state. It changes nothing. +# +# --no-fetch skips the git fetch +# --json prints the same facts as one JSON document +# --mine shows only the pull requests the caller opened +# --all shows every open pull request (the default) +# --owner=<login> shows only the pull requests the named author opened; give it once or more +# +# see: bin/status.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use status + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit status [--no-fetch] [--json] [--mine | --all | --owner=<login>…]" >&2 + exit 0 +fi + +status:render "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-tidy.sh b/bin/kit-tidy.sh new file mode 100755 index 0000000..99e1533 --- /dev/null +++ b/bin/kit-tidy.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# kit-tidy - remove merged worktrees and gone branches (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit tidy [--dry-run] +# +# Removes finished worktrees under worktrees/, and deletes each local branch whose upstream is gone. A worktree is +# finished when its pull request is merged or closed, or when it is detached, clean, and at a commit the default +# branch already has. kit tidy also prunes git's worktree records. It never touches: +# +# - the default branch +# - a branch checked out in a live worktree +# - a worktree with uncommitted changes +# - a detached worktree whose commit nothing else reaches +# - the worktree it runs in +# +# --dry-run prints what kit tidy would do, and changes nothing. +# +# see: bin/git.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use git + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit tidy [--dry-run]" >&2 + exit 0 +fi + +git:tidy "$@" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-toolchain.sh b/bin/kit-toolchain.sh new file mode 100755 index 0000000..9c6a777 --- /dev/null +++ b/bin/kit-toolchain.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# kit-toolchain - install, report, and update the tools the repository's gate runs, whatever manages them (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit toolchain list | install | outdated [--check] | update | path | run <command>… +# +# Manages the tools the gate runs, through each declared toolchain: mise, brew, or docker. The same subcommands work +# on a developer's machine and in the delivered CI job, whichever tool manager is in use. The toolchains are the +# `toolchains` setting in .kit/settings.yml, or else the ones whose file is present: mise.toml, Brewfile, or +# toolchain.Dockerfile. +# +# list prints each declared toolchain and the file it reads +# install installs every toolchain's pinned tools +# outdated [--check] prints one line per tool behind its latest version. --check fails when any is behind. +# update updates every toolchain to its latest versions, and rewrites its pins and lock +# path prints the directories that put the tools first on PATH, one per line, for a runner +# run <command>… runs the command with the tools: inside the container for docker, else with the +# toolchains' directories first on PATH +# +# Exits 0 on success, 2 on a usage error, and 1 when a subcommand fails. +# +# see: bin/toolchain.sh, docs/toolchains.md + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use toolchain + +# Every subcommand reads the declared toolchains once, here, so an invalid declaration stops every subcommand. +providers=$(toolchain:declared) || exit 1 + +usage() { echo "usage: kit toolchain list | install | outdated [--check] | update | path | run <command>…" >&2; } + +verb=${1:-} +[[ -n "${verb}" ]] && shift +case "${verb}" in + (--help | '') + usage + exit 0 + ;; + (list) + for provider in ${providers} + do + printf '%-8s %s\n' "${provider}" "$(toolchain:call "${provider}" file)" + done + ;; + (install) + if [[ -z "${providers}" ]] + then + ok "no toolchain is declared or detected; the gate runs with the tools already on PATH" + exit 0 + fi + for provider in ${providers} + do + step "${provider}: installing from $(toolchain:call "${provider}" file)" + toolchain:call "${provider}" install || { error "${provider}: the tools could not be installed; see above"; exit 1; } + done + ok "toolchains installed: ${providers//$'\n'/ }" + summary "✅ Toolchains installed: ${providers//$'\n'/ }." + ;; + (outdated) + check=false + [[ "${1:-}" == --check ]] && check=true + behind=0 + for provider in ${providers} + do + while IFS=$'\t' read -r name installed latest + do + [[ -n "${name}" ]] || continue + printf '%-8s %-32s %-16s → %s\n' "${provider}" "${name}" "${installed}" "${latest}" + behind=$(( behind + 1 )) + done < <(toolchain:call "${provider}" outdated) + done + if (( behind == 0 )) + then + ok "every tool is at its latest" + elif [[ "${check}" == true ]] + then + error "${behind} tool(s) are behind; run: kit toolchain update" + exit 1 + else + warn "${behind} tool(s) are behind; run: kit toolchain update" + fi + ;; + (update) + toolchain:each update + ok "every toolchain is updated; commit the pins and the locks it rewrote" + ;; + (path) + toolchain:path + ;; + (run) + (( $# > 0 )) || { usage; exit 2; } + toolchain:run "$@" + ;; + (*) + usage + exit 2 +esac + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-update.sh b/bin/kit-update.sh new file mode 100755 index 0000000..0e2b315 --- /dev/null +++ b/bin/kit-update.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# kit-update - move the repository, and the home directory, to the pinned release (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit update takes no arguments. It moves the repository and the home directory from the release .kit/manifest +# records (the previous release) to the pinned release. For each delivered file: +# +# - a file new in this release is written when nothing, or the same content, is there, and refused otherwise +# - a merged JSON file is merged by key +# - a region is regenerated +# - a missing file is written +# - a file unchanged since the previous release is replaced +# - a file edited since the previous release gets a three-way merge, which leaves conflict markers on a conflict +# +# A merge by key, or a three-way merge, needs the previous release as its base. The base is the first of: +# +# 1. the directory KIT_BASE names +# 2. this release, when the previous release is the same version +# 3. the previous release, which release:fetch finds or fetches (never for an unreleased previous version) +# +# kit update checks that every merge has its base before it writes anything. When a base is missing, it writes +# nothing and the manifest stays at the previous version, so the next run starts from the same place. +# +# Exits 0 when the update is complete, and 1 when a base is missing or a file needs to be resolved by hand. +# +# see: bin/release.sh + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit update" >&2 + exit 0 +fi + +guard:require tool:jq +release:require-manifest +readarray -t modules < <(release:manifest-modules) +previous=$(release:manifest-version) +current=$(release:version) +release:render-all "${kit_root}" "${modules[@]}" +section "kit update ${previous} → ${current}, modules: ${modules[*]}" + +base_root="" +if [[ -n "${KIT_BASE:-}" ]] +then + base_root=${KIT_BASE} +elif [[ "${previous}" == "${current}" ]] +then + base_root=${kit_root} +elif [[ "${previous}" != "$(release:unreleased)" ]] +then + base_root=$(release:fetch "${previous}" || true) +fi +if [[ -n "${base_root}" ]] && ! release:render-all "${base_root}" "${modules[@]}" +then + error "the base release at ${base_root} could not be read; see above" + exit 1 +fi + +# lacks-base <base> <path> <src> is true when updating the path needs the base release, and the base release is +# missing. +lacks-base() { + local base=$1 path=$2 src=$3 dest recorded kind + dest=$(release:dest "${base}" "${path}") + recorded=$(release:recorded "${base}" "${path}") + [[ -n "${recorded}" ]] || return 1 + [[ -z "${base_root}" || -z "$(release:source-of "${base_root}" "${base}" "${path}" "${modules[@]}")" ]] || return 1 + kind=$(release:kind "${src}" "${dest}") + [[ "${kind}" == merge ]] && return 0 + [[ "${kind}" != region ]] && release:installed "${src}" "${dest}" >/dev/null 2>&1 \ + && [[ "${recorded}" != "$(release:installed "${src}" "${dest}" | release:digest)" ]] +} + +# Find every path that lacks its base before writing anything, so an update is all or nothing. +skipped=0 +for base in $(release:bases) +do + while IFS=$'\t' read -r path src + do + [[ -n "${path}" ]] || continue + if lacks-base "${base}" "${path}" "${src}" + then + error "$(release:dest "${base}" "${path}") cannot be updated without the base release ${previous};" \ + "set KIT_BASE to a copy of it" + skipped=$(( skipped + 1 )) + fi + done < <(release:delivered "${kit_root}" "${base}" "${modules[@]}") +done +if (( skipped > 0 )) +then + error "${skipped} path(s) stay at ${previous}, and so does the manifest; set KIT_BASE, then run: kit update" + exit 1 +fi + +conflicts=0 +for base in $(release:bases) +do + while IFS=$'\t' read -r path src + do + [[ -n "${path}" ]] || continue + dest=$(release:dest "${base}" "${path}") + recorded=$(release:recorded "${base}" "${path}") + if [[ -z "${recorded}" ]] + then + # New in this release: written when nothing, or the same content, is there, and refused otherwise. + release:place-new "${src}" "${dest}" "new in ${current}" || conflicts=$(( conflicts + 1 )) + continue + fi + previous_src="" + [[ -n "${base_root}" ]] && previous_src=$(release:source-of "${base_root}" "${base}" "${path}" "${modules[@]}") + kind=$(release:kind "${src}" "${dest}") + if [[ "${kind}" == merge ]] + then + step "${dest}: merged by key" + release:place "${src}" "${dest}" "${previous_src}" + elif [[ "${kind}" == region ]] + then + # A region's content comes from its command, so it is regenerated, never merged. + step "${dest}: the region regenerated" + release:place "${src}" "${dest}" + elif ! release:installed "${src}" "${dest}" >/dev/null 2>&1 + then + step "${dest}: missing, written" + release:place "${src}" "${dest}" + elif [[ "${recorded}" == "$(release:installed "${src}" "${dest}" | release:digest)" ]] + then + step "${dest}: unchanged since ${previous}, replaced" + release:place "${src}" "${dest}" + else + step "${dest}: edited since ${previous}, three-way merged" + if ! git merge-file --marker-size 7 "${dest}" "${previous_src}" "${src}" + then + warn "${dest}: conflict markers are left in the file; resolve them, then run: kit check" + conflicts=$(( conflicts + 1 )) + fi + fi + done < <(release:delivered "${kit_root}" "${base}" "${modules[@]}") + release:retire "${base_root}" "${base}" "${previous}" "${current}" "${modules[@]}" +done + +release:write-manifests "${modules[@]}" +if (( conflicts > 0 )) +then + error "${conflicts} file(s) need a hand; see above." + exit 1 +fi +release:finish "${modules[@]}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/kit-version.sh b/bin/kit-version.sh new file mode 100755 index 0000000..a581bc0 --- /dev/null +++ b/bin/kit-version.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# kit-version - print this release's version (kit verb) +# ---------------------------------------------------------------------------------------------------------------------- +# +# kit version takes no arguments. It prints the version of the kit that runs, or unreleased for a checkout of the +# kit that has no version file. + +set -o errexit -o nounset -o pipefail +# shellcheck source=bin/1st.sh +source "${kit_root:?run through bin/kit}/bin/1st.sh" +use release + +if [[ "${1:-}" == --help ]] +then + echo "usage: kit version" >&2 + exit 0 +fi + +release:version + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/policy-opa.sh b/bin/policy-opa.sh new file mode 100644 index 0000000..5c979f8 --- /dev/null +++ b/bin/policy-opa.sh @@ -0,0 +1,30 @@ +# shellcheck shell=bash +# policy-opa.sh - the policy provider that runs Open Policy Agent over the Rego in .kit/policy (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/policy.sh uses this provider when the policy_engine setting is opa, or when the setting is not set and +# .kit/policy/ contains a .rego file. +# +# opa loads every .rego file in .kit/policy/. Each string in the set data.kit.policy.deny is a refusal. The input +# is the facts document, so a rule reads input.branch, input.commits, input.change, and input.policy (the +# patterns, with the kit's defaults filled in). +# +# The repository's toolchain must pin opa, like any tool its gate runs. The policy-opa module delivers +# .kit/policy/kit.rego, the patterns provider's rules written in Rego, to build on. + +policy-opa:deny() { + local input=$1 out + if ! command -v opa >/dev/null 2>&1 + then + error "pin opa in the repository's toolchain and run: kit toolchain install (the opa policy engine needs it)" + return 1 + fi + if ! out=$(opa eval --format json --data .kit/policy --input "${input}" 'data.kit.policy.deny' 2>&1) + then + error "opa could not evaluate .kit/policy: ${out}" + return 1 + fi + jq --raw-output '.result[0].expressions[0].value // [] | .[]' <<<"${out}" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/policy-patterns.sh b/bin/policy-patterns.sh new file mode 100644 index 0000000..1af24b6 --- /dev/null +++ b/bin/policy-patterns.sh @@ -0,0 +1,37 @@ +# shellcheck shell=bash +# policy-patterns.sh - the default policy provider: matches each subject against its pattern in bash (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/policy.sh uses this provider unless the repository picks another. It needs no tool. It matches each rule in +# the facts' policy object against its subject with bash's =~. A rule with an empty pattern is off. The refusals +# use the same wording as the policy-opa module's Rego, so both providers print the same messages. + +# policy-patterns:deny <input-file> prints one refusal per subject that does not match its pattern. +policy-patterns:deny() { + local input=$1 branch pattern subject sha title + branch=$(jq --raw-output '.branch // ""' "${input}") + pattern=$(jq --raw-output '.policy.branch' "${input}") + if [[ -n "${branch}" && -n "${pattern}" && ! "${branch}" =~ ${pattern} ]] + then + printf "branch '%s' does not match policy.branch: %s\n" "${branch}" "${pattern}" + fi + pattern=$(jq --raw-output '.policy.commit_subject' "${input}") + if [[ -n "${pattern}" ]] + then + while IFS=$'\x1f' read -r sha subject + do + [[ -n "${sha}" && ! "${subject}" =~ ${pattern} ]] || continue + printf "commit %s '%s' does not match policy.commit_subject: %s\n" "${sha:0:7}" "${subject}" "${pattern}" + done < <(jq --raw-output '.commits[] | "\(.sha)\u001f\(.subject)"' "${input}") + fi + title=$(jq --raw-output '.change.title // ""' "${input}") + pattern=$(jq --raw-output '.policy.change_title' "${input}") + [[ "$(jq --raw-output '.change != null' "${input}")" == true ]] || return 0 + if [[ -n "${pattern}" && ! "${title}" =~ ${pattern} ]] + then + printf "change title '%s' does not match policy.change_title: %s\n" "${title}" "${pattern}" + fi + return 0 +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/policy.sh b/bin/policy.sh new file mode 100644 index 0000000..82d32da --- /dev/null +++ b/bin/policy.sh @@ -0,0 +1,156 @@ +# shellcheck shell=bash +# policy.sh - one interface to the engine that checks branch names, commit subjects, and change titles (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# A policy sets what a branch name, a commit subject, or a change title may be. kit policy enforces it: it gathers +# the facts, reads the rules, and calls a provider (the policy engine) to list every refusal. A provider is +# policy-<name>.sh in this directory, whose functions are named policy-<name>:<call>. +# +# The facts are one JSON document, built by policy:input. `kit policy --input` prints it, to write a rule against. +# It contains: +# +# - the branch and the default branch +# - the commits past the fork from the default branch, merges excluded +# - the open change on the branch, with its title and body +# - the policy settings, with the kit's defaults filled in +# +# The rules are the `policy:` map in .kit/settings.yml, one extended regular expression per subject. A subject +# that is not set uses the kit's default below. An empty pattern turns that rule off. +# +# branch the branch's name +# commit_subject the subject of every commit past the fork, merges excluded +# change_title the open change's title +# +# The provider is chosen by: +# +# 1. the `policy_engine` setting in .kit/settings.yml, if set +# 2. otherwise opa, if .kit/policy/ contains a .rego file +# 3. otherwise patterns +# +# The providers: +# +# patterns the default: matches each pattern in bash, with no tool needed +# opa runs Open Policy Agent over the Rego in .kit/policy/ (package kit.policy, rule deny) +# +# Every provider implements one call: +# +# deny <input-file> one refusal per line for the facts document in <input-file> +# +# Both providers read the same facts document. A rule that a pattern cannot express can be written in Rego +# alongside the patterns. The policy-opa module delivers the default patterns as Rego, to start from. +# +# Bash matches a pattern as an extended regular expression; OPA matches it as RE2. Write patterns in the subset +# both support: character classes, anchors, alternation, and counted repetition. +# +# see: docs/policy.md + +use release yaml git + +# The kit's defaults follow the workflow in .kit/1st.md: +# +# - a branch is <key>-<slug>, as kit issue makes it +# - a commit subject starts with its issue's reference, #7 or AB#4521 +# - a change title is one line of at most 120 characters +declare -gr policy_default_branch='^[a-z0-9]+-[a-z0-9][a-z0-9-]*$' +declare -gr policy_default_commit_subject='^([A-Z]+)?#[0-9]+ [^ ]' +declare -gr policy_default_change_title='^[^[:cntrl:]]{1,120}$' + +# policy:rules prints the policy settings with the kit's defaults filled in, as a JSON object. +policy:rules() { + local settings key value + local -A rule=( + [branch]=${policy_default_branch} + [commit_subject]=${policy_default_commit_subject} + [change_title]=${policy_default_change_title} + ) + settings=$(release:settings) + while IFS=$'\t' read -r key value + do + [[ -n "${key}" ]] || continue + if [[ -z "${rule[${key}]+x}" ]] + then + error "${settings}: policy has a subject the kit does not know: ${key} (the subjects: branch," \ + "commit_subject, change_title)" + return 1 + fi + rule[${key}]=${value} + done < <(yaml:map "${settings}" policy) + jq --null-input --arg branch "${rule[branch]}" --arg commit_subject "${rule[commit_subject]}" \ + --arg change_title "${rule[change_title]}" \ + '{branch: $branch, commit_subject: $commit_subject, change_title: $change_title}' +} + +# policy:branch prints the branch to check: the change's source branch on a CI runner, otherwise the current +# branch, or nothing on a detached HEAD. +policy:branch() { + local branch=${GITHUB_HEAD_REF:-${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:-}} + [[ -n "${branch}" ]] || branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) + printf '%s\n' "${branch}" +} + +# policy:input prints the facts document described in the file header. The commits are those past the fork from +# origin's default branch. When the fork point is missing, as in a shallow clone, it warns and omits the commits +# instead of guessing. The change is read from the forge (GitHub or GitLab). It is omitted when there is no forge +# or no open change. On the default branch or a detached HEAD, the branch is null and there are no commits or +# change. +policy:input() { + local default_branch branch commits='[]' change='null' rules number title body + rules=$(policy:rules) || return 1 + default_branch=$(git:default-branch) + branch=$(policy:branch) + if [[ -n "${branch}" && "${branch}" != "${default_branch}" ]] + then + if git rev-parse --verify --quiet "origin/${default_branch}" >/dev/null \ + && git merge-base "origin/${default_branch}" HEAD >/dev/null 2>&1 + then + commits=$(git log --no-merges --format='%H%x1f%s' "origin/${default_branch}..HEAD" \ + | jq --raw-input --slurp 'split("\n") | map(select(. != "") | split("\u001f") | {sha: .[0], subject: .[1]})') + else + warn "the fork from origin/${default_branch} is not here, so no commit is checked; fetch with full history" + fi + if use forge 2>/dev/null && number=$(forge:pr-here 2>/dev/null) && [[ -n "${number}" ]] + then + title=$(forge:pr-title "${number}" 2>/dev/null || true) + body=$(forge:pr-body "${number}" 2>/dev/null || true) + change=$(jq --null-input --argjson number "${number}" --arg title "${title}" --arg body "${body}" \ + '{number: $number, title: $title, body: $body}') + fi + else + branch='' + fi + jq --null-input --arg branch "${branch}" --arg default_branch "${default_branch}" --argjson commits "${commits}" \ + --argjson change "${change}" --argjson policy "${rules}" \ + '{branch: (if $branch == "" then null else $branch end), default_branch: $default_branch, commits: $commits, + change: $change, policy: $policy}' +} + +# policy:engine prints the provider's name, chosen as the file header describes. It fails, naming the engine, +# when the kit has no provider by that name. +policy:engine() { + local engine + engine=$(release:setting policy_engine) + if [[ -z "${engine}" ]] + then + engine=patterns + compgen -G '.kit/policy/*.rego' >/dev/null && engine=opa + fi + # shellcheck disable=SC2154 # kit_bin_dir is declared by bin/1st.sh + if [[ ! -f "${kit_bin_dir}/policy-${engine}.sh" ]] + then + error "'${engine}' is not a policy engine the kit knows; the engines are: patterns opa (policy_engine in" \ + ".kit/settings.yml)" + return 1 + fi + printf '%s\n' "${engine}" +} + +# policy:deny <input-file> prints every refusal the provider finds in the facts, one per line, sorted and without +# duplicates. +policy:deny() { + local engine + engine=$(policy:engine) || return 1 + use "policy-${engine}" || return 1 + "policy-${engine}:deny" "$1" | LC_ALL=C sort --unique +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/release.sh b/bin/release.sh new file mode 100644 index 0000000..2f29d5b --- /dev/null +++ b/bin/release.sh @@ -0,0 +1,1093 @@ +# shellcheck shell=bash +# release.sh - reads a release's modules, renders templates and regions, merges JSON, writes manifests (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# shellcheck disable=SC2016 # jq filters name jq variables with $, in single quotes on purpose +# shellcheck disable=SC2154 # kit_root is exported by bin/kit +# +# A release is bin/ and modules/. Each modules/<name>/ is a module, which contains: +# +# module.yml the module's metadata +# files/<base>/ files copied to the base's root, at the same paths +# templates/<base>/ files whose @key@ placeholders are filled from the repository's settings, then copied the +# same way +# regions/<base>/ files rendered like templates, each placed as a region inside a file the repository owns +# +# A region is the part of a repository's file that the kit owns. It sits between a bare kit:generate line and an +# end line, both naming the path. The rest of the file belongs to the repository. kit generate replaces the +# region's content with what kit region prints. +# +# A repository can define its own modules the same way, under .kit/modules/<name>/. A module name is looked up in +# the release first, then there. +# +# The bases a module delivers to: +# +# repo the repository root, recorded in .kit/manifest +# home the user's home directory, recorded in ~/.kit/manifest, which every repository on the machine shares +# +# How a delivered file is placed: +# +# - a destination ending in .json (except *.schema.json) is merged by key; the kit owns only the entries it +# writes +# - a region is regenerated, never merged +# - every other file is copied +# +# The repository's choices are in .kit/settings.yml. The kit reads that file and never writes it. +# +# Every function reads a release from a root directory: kit_root by default, which bin/kit exports. bin/kit also +# changes to the repository root, so .kit/manifest and every repo path here are relative to it. Every sort and +# comm here uses the C locale, so an order written on one machine reads the same on another. +# +# see: docs/modules.md + +use yaml + +# release:unreleased prints `unreleased`, the version of a kit checkout. The release workflow +# (.github/workflows/release.yml) writes a version file into each release; a checkout has none. kit update never +# fetches this version. +release:unreleased() { printf 'unreleased\n'; } + +# release:version prints this release's version. +release:version() { + cat "${kit_root}/version" 2>/dev/null || release:unreleased +} + +# release:bases prints the bases a module can deliver to. Each base is a subdirectory name under files/, +# templates/, and regions/. +release:bases() { printf 'repo\nhome\n'; } + +# release:base-root <base> prints the directory a base's files are copied to: `.` (the repository root, where the +# verbs run) for repo, and the home directory for home. KIT_HOME, when set, replaces the home directory, so a test +# can use a stand-in. +release:base-root() { + case "$1" in + (repo) printf '.\n' ;; + (home) printf '%s\n' "${KIT_HOME:-${HOME}}" ;; + (*) + error "'$1' is not a base; the bases are: $(release:bases | paste -s -d ' ' -)" + return 2 + esac +} + +# release:dest <base> <path> prints where a delivered path is written. +release:dest() { + if [[ "$1" == repo ]] + then printf '%s\n' "$2" + else printf '%s/%s\n' "$(release:base-root "$1")" "$2" + fi +} + +# release:manifest <base> prints the base's manifest: .kit/manifest in the repository, ~/.kit/manifest for home. +release:manifest() { release:dest "$1" .kit/manifest; } + +# release_local is the root of the repository's own modules, .kit/modules/<name>/, laid out like the release's. +declare -gr release_local=.kit + +# release:settings prints the path of the repository's settings file. +release:settings() { printf '.kit/settings.yml\n'; } + +# release:setting <key> prints the value of one top-level setting, or nothing. +release:setting() { yaml:value "$(release:settings)" "$1"; } + +# release:modules [<root>] prints the modules in reach: every modules/<name>/ that contains a module.yml, in the +# release at <root> and in the repository's .kit/modules/. +release:modules() { + local root=${1:-${kit_root}} dir + for dir in "${root}" "${release_local}" + do + [[ -d "${dir}/modules" ]] || continue + (cd "${dir}/modules" && find . -mindepth 2 -maxdepth 2 -name module.yml | sed 's@^\./@@; s@/module.yml$@@') + done | LC_ALL=C sort --unique +} + +# release:home <root> <module> prints the directory that contains the module: <root> when the release has the +# module, otherwise the repository's .kit. The module's files are under <home>/modules/<module>/. +release:home() { + if [[ -f "$1/modules/$2/module.yml" || ! -f "${release_local}/modules/$2/module.yml" ]] + then printf '%s\n' "$1" + else printf '%s\n' "${release_local}" + fi +} + +# release:origin <root> <module> prints where the module comes from: "release" or "repository". +release:origin() { + if [[ "$(release:home "$1" "$2")" == "$1" ]] + then printf 'release\n' + else printf 'repository\n' + fi +} + +# release:meta <root> <module> prints the path of the module's metadata. +release:meta() { printf '%s/modules/%s/module.yml\n' "$(release:home "$1" "$2")" "$2"; } + +# release:description <root> <module> prints the module's one-line description. +release:description() { yaml:value "$(release:meta "$1" "$2")" description; } + +# release:depends <root> <module> prints the modules this one depends on, one per line. +release:depends() { yaml:list "$(release:meta "$1" "$2")" depends; } + +# release:default-modules <root> prints the modules whose default is true. They are active unless removed. +release:default-modules() { + local module + for module in $(release:modules "$1") + do + [[ "$(yaml:value "$(release:meta "$1" "${module}")" default)" == true ]] && printf '%s\n' "${module}" + done + return 0 +} + +# release:require-modules <root> <name>… fails on a module that is not in reach, and lists the modules that are. +release:require-modules() { + local root=$1 module + shift + for module in "$@" + do + if [[ ! -f "$(release:meta "${root}" "${module}")" ]] + then + error "'${module}' is not a module in reach; the modules are:" \ + "$(release:modules "${root}" | paste -s -d ' ' -)" + return 2 + fi + done +} + +# release:resolve <root> <name>… prints the named modules and all their dependencies, each once. Each module +# comes after the modules it depends on. Ties are broken alphabetically, so the order is the same on every +# machine. It fails on a dependency cycle. +release:resolve() { + local root=$1 module dep ready placed_count=0 + shift + (( $# > 0 )) || return 0 + local -A wanted=() placed=() + local -a queue=("$@") names=() + while (( ${#queue[@]} > 0 )) + do + module=${queue[0]} + queue=("${queue[@]:1}") + [[ -n "${wanted[${module}]:-}" ]] && continue + wanted[${module}]=1 + for dep in $(release:depends "${root}" "${module}") + do + queue+=("${dep}") + done + done + readarray -t names < <(printf '%s\n' "${!wanted[@]}" | LC_ALL=C sort) + while (( placed_count < ${#names[@]} )) + do + ready='' + for module in "${names[@]}" + do + [[ -n "${placed[${module}]:-}" ]] && continue + for dep in $(release:depends "${root}" "${module}") + do + [[ -n "${placed[${dep}]:-}" ]] || continue 2 + done + ready=${module} + break + done + if [[ -z "${ready}" ]] + then + error "the modules' depends lines form a cycle among: ${names[*]}" + return 1 + fi + placed[${ready}]=1 + placed_count=$(( placed_count + 1 )) + printf '%s\n' "${ready}" + done +} + +# release:dependents <root> <module> <active>… prints the active modules that depend on <module>. +release:dependents() { + local root=$1 module=$2 other dep + shift 2 + for other in "$@" + do + for dep in $(release:depends "${root}" "${other}") + do + [[ "${dep}" == "${module}" ]] && printf '%s\n' "${other}" + done + done +} + +# release:validate <root> <module> fails on metadata that the schema rejects, as far as bash can check without a +# validator: +# +# - a key the kit does not know +# - a missing description +# - a default that is not true or false +# - a dependency on a module that is not in reach +# +# The gate validates against modules/module.schema.json in full. +release:validate() { + local root=$1 module=$2 meta key value dep + meta=$(release:meta "${root}" "${module}") + for key in $(yaml:keys "${meta}") + do + case "${key}" in + (description | default | depends | answers | expects) ;; + (*) + error "$(release:meta "${root}" "${module}") has a key the kit does not know: ${key}" \ + "(see modules/module.schema.json)" + return 1 + esac + done + if [[ -z "$(yaml:value "${meta}" description)" ]] + then + error "$(release:meta "${root}" "${module}") has no description" + return 1 + fi + value=$(yaml:value "${meta}" default) + if [[ -n "${value}" && "${value}" != true && "${value}" != false ]] + then + error "$(release:meta "${root}" "${module}"): default must be true or false, not '${value}'" + return 1 + fi + for dep in $(release:depends "${root}" "${module}") + do + if [[ ! -f "$(release:meta "${root}" "${dep}")" ]] + then + error "$(release:meta "${root}" "${module}") depends on '${dep}', which is not in reach" + return 1 + fi + done +} + +# release:expectations <root> <module>… prints "<path>\x1f<pattern>\x1f<remedy>" for each line the modules expect +# in a file the repository owns. Such a line is the include that pulls in the kit's file, such as lefthook's +# extends or just's import. +release:expectations() { + local root=$1 module + shift + for module in "$@" + do + yaml:records "$(release:meta "${root}" "${module}")" expects path pattern remedy + done +} + +# release:unmet <root> <module>… prints the remedy for each expectation the repository does not meet, one per +# line. An expectation is unmet when its file is missing, or when no line in the file matches its pattern (an +# extended regular expression). The kit never writes to such a file; it only reports what is missing. +release:unmet() { + local root=$1 path pattern remedy + shift + while IFS=$'\x1f' read -r path pattern remedy + do + [[ -n "${path}" ]] || continue + if [[ ! -f "${path}" ]] || ! grep --quiet --extended-regexp -- "${pattern}" "${path}" + then + printf '%s: %s\n' "${path}" "${remedy:-a line matching ${pattern} is expected}" + fi + done < <(release:expectations "${root}" "$@") + return 0 +} + +# release:validate-settings fails on a settings file that the schema rejects, as far as bash can check: +# +# - a key the kit does not know +# - a require_signing that is not true or false +# - an answer key with characters outside [a-z_] +# - a tracker without a prefix (letters and an optional #) or without a provider +# +# The gate validates this repository's settings against .kit/settings.schema.json in full. The kit delivers the +# schema so an editor can validate against it anywhere. +release:validate-settings() { + local settings key value + settings=$(release:settings) + [[ -f "${settings}" ]] || return 0 + for key in $(yaml:keys "${settings}") + do + case "${key}" in + (forge | gate | require_signing | answers | trackers | toolchains | policy | policy_engine) ;; + (*) + error "${settings} has a key the kit does not know: ${key} (see .kit/settings.schema.json)" + return 1 + esac + done + value=$(yaml:value "${settings}" require_signing) + if [[ -n "${value}" && "${value}" != true && "${value}" != false ]] + then + error "${settings}: require_signing must be true or false, not '${value}'" + return 1 + fi + while IFS=$'\t' read -r key value + do + [[ -z "${key}" || "${key}" =~ ^[a-z_]+$ ]] && continue + error "${settings}: the answer key '${key}' is not letters and underscores, as a template's @key@ must be" + return 1 + done < <(yaml:map "${settings}" answers) + while IFS=$'\x1f' read -r key value + do + [[ -n "${key}${value}" ]] || continue + if [[ -z "${key}" || ! "${key}" =~ ^[A-Za-z]*#?$ || -z "${value}" ]] + then + error "${settings}: a tracker needs a prefix, letters and an optional #, and a provider (prefix '${key}')" + return 1 + fi + done < <(yaml:records "${settings}" trackers prefix provider) +} + +# release:context <root> <module> prints "<key>\t<value>" for every key a template or region of the module can +# use. The keys come from: +# +# 1. the module's defaults under answers: in module.yml, as <key> and as answers.<key> +# 2. .kit/settings.yml: each top-level scalar as <key>, each map entry as <map>.<name>, and each entry of the +# answers map also as <name> +# +# A later source overrides an earlier one, so the repository's answer wins over the module's default. +release:context() { + local settings key name value + settings=$(release:settings) + { + while IFS=$'\t' read -r name value + do + [[ -n "${name}" ]] || continue + printf '%s\t%s\n%s\t%s\n' "${name}" "${value}" "answers.${name}" "${value}" + done < <(yaml:map "$(release:meta "$1" "$2")" answers) + for key in $(yaml:keys "${settings}") + do + value=$(yaml:value "${settings}" "${key}") + [[ -n "${value}" ]] && printf '%s\t%s\n' "${key}" "${value}" + while IFS=$'\t' read -r name value + do + [[ -n "${name}" ]] || continue + printf '%s\t%s\n' "${key}.${name}" "${value}" + [[ "${key}" == answers ]] && printf '%s\t%s\n' "${name}" "${value}" + done < <(yaml:map "${settings}" "${key}") + done + } | awk -F '\t' '{ a[$1] = $2 } END { for (k in a) print k "\t" a[k] }' +} + +# release:tmp prints a directory for this process's rendered templates. It is created on first use and removed +# at exit. release:render-all does the rendering; every verb runs it before reading a release. +release:tmp() { + if [[ -z "${release_tmp:-}" ]] + then + release_tmp=$(mktemp -d) + # shellcheck disable=SC2064 # expanded now on purpose: the path is fixed from here on + trap "rm -rf '${release_tmp}'" EXIT + fi + printf '%s\n' "${release_tmp}" +} + +# release:render <root> <module> <kind> <base> renders the module's <kind> (templates or regions) for <base>, once +# per process, and prints the directory that contains the result. It prints nothing when the module has none. The +# directory keeps the modules/<module>/<kind>/<base> layout, so a rendered region is still recognized as a region. +# +# A placeholder is written @key@ (autoconf's form), which cannot be confused with bash's ${var} or GitHub's +# ${{ }}. A key can be dotted: @answers.key@. A key with no value fails, and the error names the key. +release:render() { + local root=$1 module=$2 kind=$3 base=$4 source out key value text file rel map + local -A answer=() + source="$(release:home "${root}" "${module}")/modules/${module}/${kind}/${base}" + [[ -d "${source}" ]] || return 0 + out="$(release:tmp)/$(printf '%s' "${root}" | release:digest)/modules/${module}/${kind}/${base}" + if [[ ! -d "${out}" ]] + then + while IFS=$'\t' read -r key value + do + [[ -n "${key}" ]] && answer[${key}]=${value} + done < <(release:context "${root}" "${module}") + while IFS= read -r file + do + rel=${file#"${source}"/} + text=$(<"${file}") + for key in $(grep --only-matching '@[a-z_][a-z_.]*@' "${file}" | LC_ALL=C sort --unique | tr -d '@') + do + if [[ -z "${answer[${key}]+x}" ]] + then + map=answers + [[ "${key}" == *.* ]] && map=${key%.*} + error "${rel} needs an answer for '${key}'; add '${key##*.}: <value>' under ${map}: in .kit/settings.yml" \ + "(module ${module})" + return 1 + fi + text=${text//"@${key}@"/"${answer[${key}]}"} + done + mkdir -p "${out}/$(dirname "${rel}")" + printf '%s\n' "${text}" > "${out}/${rel}" + done < <(find "${source}" -type f) + fi + printf '%s\n' "${out}" +} + +# release:render-all <root> <module>… prepares the release at <root> for the named modules. In order, it: +# +# 1. validates the settings +# 2. fails on a named module that is not in reach +# 3. validates every module in reach +# 4. fails on a base the kit does not deliver to +# 5. renders the named modules' templates and regions +# 6. fails on a delivered file that is a link to nothing, and on regions that release:regions-apart rejects +release:render-all() { + local root=$1 module kind base + shift + release:validate-settings || return 1 + release:require-modules "${root}" "$@" || return 2 + for module in $(release:modules "${root}") + do + release:validate "${root}" "${module}" || return 1 + done + for module in "$@" + do + for kind in files templates regions + do + for base in $(cd "$(release:home "${root}" "${module}")/modules/${module}/${kind}" 2>/dev/null \ + && find . -mindepth 1 -maxdepth 1 -type d | sed 's@^\./@@') + do + # shellcheck disable=SC2046 # the bases are single words + if ! release:among "${base}" $(release:bases) + then + error "modules/${module}/${kind}/${base} names a base the kit does not deliver to; the bases are:" \ + "$(release:bases | paste -s -d ' ' -)" + return 1 + fi + done + done + for kind in templates regions + do + for base in $(release:bases) + do + release:render "${root}" "${module}" "${kind}" "${base}" >/dev/null || return 1 + done + done + done + for base in $(release:bases) + do + release:delivered "${root}" "${base}" "$@" >/dev/null || return 1 + release:regions-apart "${root}" "${base}" "$@" || return 1 + done +} + +# release:regions-apart <root> <base> <module>… fails when two of the modules deliver a region to the same path (a +# file can have only one kit region), or when a region is in a kind of file whose comment syntax the kit does not +# know. +release:regions-apart() { + local root=$1 base=$2 module home file rel owner + shift 2 + local -A owner_of=() + for module in "$@" + do + home=$(release:home "${root}" "${module}") + [[ -d "${home}/modules/${module}/regions/${base}" ]] || continue + while IFS= read -r file + do + rel=${file#"${home}/modules/${module}/regions/${base}/"} + if ! release:region-known "${rel}" + then + error "modules/${module}/regions/${base}/${rel}: the kit knows no comment syntax for that kind of file," \ + "so it cannot mark a region there" + return 1 + fi + owner=${owner_of[${rel}]:-} + if [[ -n "${owner}" ]] + then + error "modules ${owner} and ${module} both deliver a region to ${rel}; one file holds one kit region" + return 1 + fi + owner_of[${rel}]=${module} + done < <(find "${home}/modules/${module}/regions/${base}" -type f) + done +} + +# release:layers <root> <base> <module>… prints the directories copied to the base's root, in module order: each +# module's files/<base>, its rendered templates/<base>, and its rendered regions/<base>. +release:layers() { + local root=$1 base=$2 module home kind rendered + shift 2 + for module in "$@" + do + home=$(release:home "${root}" "${module}") + [[ -d "${home}/modules/${module}/files/${base}" ]] && printf '%s\n' "${home}/modules/${module}/files/${base}" + for kind in templates regions + do + rendered=$(release:render "${root}" "${module}" "${kind}" "${base}") + [[ -n "${rendered}" ]] && printf '%s\n' "${rendered}" + done + done + return 0 +} + +# release:kind <source> <path> prints how the content is placed: +# +# region the source is under a module's regions/<base>/ +# merge the destination is a merged .json file (see release:merged) +# copy anything else +# +# The region test looks only at the source's last modules/ segment, so a checkout under a directory named modules +# works the same as one anywhere else. +release:kind() { + if [[ "${1##*/modules/}" =~ ^[^/]+/regions/(repo|home)/ ]] + then printf 'region\n' + elif release:merged "$2" + then printf 'merge\n' + else printf 'copy\n' + fi +} + +# release:region-name <dest> prints the region's name: the path relative to the base's root. +release:region-name() { + local home + home=$(release:base-root home) + if [[ "$1" == "${home}"/* ]] + then printf '%s\n' "${1#"${home}"/}" + else printf '%s\n' "$1" + fi +} + +# release:region-command <dest> prints the command that generates the region. Under the repository it prints +# nothing, because the bare directive means `kit region <name>`. Under the home directory it prints +# `kit region --home <name>`, which the bare form cannot express. +release:region-command() { + local home + home=$(release:base-root home) + if [[ "$1" == "${home}"/* ]] + then printf 'kit region --home %s\n' "${1#"${home}"/}" + fi +} + +# release:region-markers <dest> prints the region's two marker lines in the file's comment syntax, chosen by +# extension: an HTML comment, //, --, or #. The first line is the directive, `kit:generate <name>`, followed by +# the command in backticks when there is one. The second is the end marker, `kit:end <name>`. A kind of file with +# no comment syntax the kit knows is rejected earlier, by release:render-all, when it reads the module. +release:region-markers() { + local open close name command + case "${1##*.}" in + (md | markdown | html | htm | xml | svg) open='<!-- ' close=' -->' ;; + (js | mjs | cjs | ts | tsx | jsx | go | rs | c | h | cc | cpp | hpp | java | kt | swift | cs | scala) + open='// ' close='' ;; + (sql | lua | hs) open='-- ' close='' ;; + (*) open='# ' close='' ;; + esac + name=$(release:region-name "$1") + command=$(release:region-command "$1") + printf '%skit:generate %s%s%s\n%skit:end %s%s\n' "${open}" "${name}" "${command:+ \`${command}\`}" "${close}" \ + "${open}" "${name}" "${close}" +} + +# release:region-known <path> succeeds when the kit knows the file's comment syntax, so a region can be marked in +# it. +release:region-known() { + case "${1##*/}" in + (*.json | *.jsonc | *.json5) return 1 ;; + (*.*) case "${1##*.}" in + (md | markdown | html | htm | xml | svg | js | mjs | cjs | ts | tsx | jsx | go | rs | c | h | cc | cpp | hpp \ + | java | kt | swift | cs | scala | sql | lua | hs | sh | bash | zsh | yml | yaml | toml | ini | cfg | conf \ + | py | rb | pl | txt | gitignore | gitattributes | editorconfig | env | just | mk | Makefile) return 0 ;; + (*) return 1 ;; + esac ;; + (*) return 0 ;; + esac +} + +# release:target <path> prints the file that a write to <path> changes: the path itself, or the target of a +# symlink there. A repository that keeps AGENTS.md or a settings file as a link into another directory keeps the +# link. +release:target() { + if [[ -L "$1" ]] + then readlink -f "$1" + else printf '%s\n' "$1" + fi +} + +# release:region-has <dest> succeeds when the file contains the region's directive. A directive with no end +# marker fails (status 2) and names the file, because a write would drop everything after the directive. +release:region-has() { + local path=$1 begin end + [[ -f "${path}" ]] || return 1 + { read -r begin; read -r end; } < <(release:region-markers "$1") + grep --quiet --line-regexp --fixed-strings "${begin}" "${path}" || return 1 + if ! grep --quiet --line-regexp --fixed-strings "${end}" "${path}" + then + error "${path}: the kit's region opens with '${begin}' and never closes; add '${end}' where it ends" + return 2 + fi +} + +# release:region-read <dest> prints the region's content, between its directive and its end marker. It fails +# when the file or the region is missing. +release:region-read() { + local path=$1 begin end + release:region-has "$1" || return $? + { read -r begin; read -r end; } < <(release:region-markers "$1") + awk -v b="${begin}" -v e="${end}" '$0 == e { on = 0 } on { print } $0 == b { on = 1 }' "${path}" +} + +# release:region-write <dest> <content-file> sets the region's content to <content-file>, the output of the +# region's command. It writes the content: +# +# - between the directive and the end marker, when the file has them +# - after a blank line at the end of the file, when the file has no region +# - as the whole file, when there is no file +release:region-write() { + local path content=$2 begin end status=0 + path=$(release:target "$1") + { read -r begin; read -r end; } < <(release:region-markers "$1") + mkdir -p "$(dirname "${path}")" + release:region-has "${path}" || status=$? + (( status == 2 )) && return 1 + if (( status == 0 )) + then + awk -v b="${begin}" -v e="${end}" -v c="${content}" ' + $0 == b { print; while ((getline line < c) > 0) print line; skip = 1; next } + $0 == e { skip = 0 } + !skip { print }' "${path}" > "${path}.kit-new" + else + { + if [[ -s "${path}" ]] + then + cat "${path}" + [[ "$(tail -c 1 "${path}")" == "" ]] || printf '\n' + printf '\n' + fi + printf '%s\n' "${begin}" + cat "${content}" + printf '%s\n' "${end}" + } > "${path}.kit-new" + fi + mv "${path}.kit-new" "${path}" +} + +# release:region-remove <dest> removes the region, its directive, its end marker, and the blank line before it. +# A file left empty is deleted. +release:region-remove() { + local path begin end + path=$(release:target "$1") + [[ -f "${path}" ]] || return 0 + { read -r begin; read -r end; } < <(release:region-markers "$1") + awk -v b="${begin}" -v e="${end}" ' + $0 == b { skip = 1; held = 0; next } + $0 == e { skip = 0; next } + skip { next } + held && printed { print ""; held = 0 } + $0 == "" { held = 1; next } + { print; printed = 1 }' "${path}" > "${path}.kit-new" + if [[ -s "${path}.kit-new" ]] + then mv "${path}.kit-new" "${path}" + else rm -f "${path}.kit-new" "${path}" + fi +} + +# release:delivered <root> <base> <module>… prints "<path>\t<source>" for every path under the base's root that +# the release at <root> delivers for those modules, sorted by path. When layers deliver the same path, the last +# layer wins. A symlink counts as a source only when it points at a file. A dangling symlink fails here, where it +# is found. +release:delivered() { + local layer path src + while IFS= read -r layer + do + (cd "${layer}" && find . \( -type f -o -type l \) | sed 's@^\./@@' \ + | awk -v l="${layer}" '{ print $0 "\t" l "/" $0 }') + done < <(release:layers "$@") | awk -F '\t' '{ src[$1] = $2 } END { for (p in src) print p "\t" src[p] }' \ + | LC_ALL=C sort \ + | while IFS=$'\t' read -r path src + do + if [[ -L "${src}" && ! -e "${src}" ]] + then + error "${src} is a link to nothing; the release is broken" + return 1 + fi + printf '%s\t%s\n' "${path}" "${src}" + done +} + +# release:source-of <root> <base> <path> <module>… prints the release file behind one delivered path, or nothing. +release:source-of() { + local root=$1 base=$2 path=$3 + shift 3 + release:delivered "${root}" "${base}" "$@" | awk -F '\t' -v p="${path}" '$1 == p { print $2 }' +} + +# release:paths-beyond <root> <base> <before> <after> prints the paths under the base that the module set <after> +# delivers and <before> does not. Each set is passed as one space-separated word. +release:paths-beyond() { + local root=$1 base=$2 + local -a before after + read -r -a before <<<"$3" + read -r -a after <<<"$4" + LC_ALL=C comm -13 <(release:delivered "${root}" "${base}" "${before[@]}" | cut -f 1) \ + <(release:delivered "${root}" "${base}" "${after[@]}" | cut -f 1) +} + +# release:merged <path> succeeds for a destination that is merged by key instead of copied: a .json settings +# file. Every .json file the kit delivers is a settings file, except a schema (*.schema.json). +release:merged() { [[ "$1" == *.json && "$1" != *.schema.json ]]; } + +# release:fetch <version> prints the root of that release, fetching it if needed. It tries, in order: +# +# 1. the kit's cache +# 2. a copy installed by mise, when mise is present +# 3. the release's public tarball, downloaded with curl and unpacked into the cache +# +# The version comes from a manifest, so it must have a version's shape before it is used in a path. +release:fetch() { + local version=$1 root cache fresh + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] + then + error "'${version}' is not a kit version" + return 1 + fi + cache="${XDG_CACHE_HOME:-${HOME}/.cache}/kit/kit-${version}" + if [[ -x "${cache}/bin/kit" ]] + then + printf '%s\n' "${cache}" + return 0 + fi + if command -v mise >/dev/null 2>&1 + then + root=$(mise where "github:empowerite/kit@${version}" 2>/dev/null || true) + if [[ -x "${root}/bin/kit" ]] + then + printf '%s\n' "${root}" + return 0 + fi + fi + step "Fetching kit ${version} from its release" + fresh=$(mktemp -d) + if ! curl --fail --silent --location \ + "https://github.com/empowerite/kit/releases/download/release/${version}/kit-${version}.tar.gz" 2>/dev/null \ + | tar --extract --gzip --directory "${fresh}" --strip-components 1 2>/dev/null + then + rm -rf "${fresh}" + error "kit ${version} could not be fetched from its release; set KIT_BASE to a copy of it" + return 1 + fi + mkdir -p "$(dirname "${cache}")" + mv "${fresh}" "${cache}" + printf '%s\n' "${cache}" +} + +# release:among <name> <name>… succeeds when the first name is one of the rest. +release:among() { + local needle=$1 item + shift + for item in "$@" + do + [[ "${item}" == "${needle}" ]] && return 0 + done + return 1 +} + +# release:without <name> <name>… prints the rest without the first name, one per line. +release:without() { + local needle=$1 item + shift + for item in "$@" + do + [[ "${item}" == "${needle}" ]] || printf '%s\n' "${item}" + done +} + +# The JSON merge functions, in jq: +# +# merge($cur; $base; $new) applies the release's $new over the current $cur, key by key. For an array, it keeps +# the current entries that were not in the previous release's $base, then adds $new's +# unmerge($cur; $base) removes $base's array entries from $cur, key by key; scalar values stay +# holds($cur; $new) true when $cur contains every key, array entry, and value in $new +# +# The parameters are values ($name), not filters, so each is evaluated once, at the right level. +release_jq=' + def merge($cur; $base; $new): + if ($new | type) == "object" then + reduce ($new | keys[]) as $k ($cur // {}; .[$k] = merge(($cur // {})[$k]; ($base // {})[$k]; $new[$k])) + elif ($new | type) == "array" then + ((($cur // []) - ($base // [])) as $kept | $kept + ($new - $kept)) + else $new end; + def unmerge($cur; $base): + if ($base | type) == "object" then + reduce ($base | keys[]) as $k ($cur; if has($k) then .[$k] = unmerge($cur[$k]; $base[$k]) else . end) + elif ($base | type) == "array" then ($cur // []) - $base + else $cur end; + def holds($cur; $new): + if ($new | type) == "object" then + all($new | keys[]; . as $k | ($cur | type) == "object" and ($cur | has($k)) and holds($cur[$k]; $new[$k])) + elif ($new | type) == "array" then all($new[]; . as $e | any(($cur // [])[]; . == $e)) + else $cur == $new end; +' + +# release:json-write <path> <filter> [<jq option>…] rewrites the JSON at <path> through <filter>. It creates the +# file, as {}, when it is missing. +release:json-write() { + local path filter=$2 + path=$(release:target "$1") + shift 2 + mkdir -p "$(dirname "${path}")" + [[ -f "${path}" ]] || printf '{}\n' > "${path}" + jq --indent 2 "${release_jq} ${filter}" "$@" "${path}" > "${path}.kit-new" + mv "${path}.kit-new" "${path}" +} + +# release:json-holds <path> <source> succeeds when the JSON at <path> contains every entry that the release's +# <source> writes. +release:json-holds() { + jq --exit-status "${release_jq} holds(.; \$new[0])" --slurpfile new "$2" "$1" >/dev/null +} + +# release:digest prints the sha256 of stdin, using sha256sum or shasum, whichever the machine has. +release:digest() { + if command -v sha256sum >/dev/null 2>&1 + then + sha256sum | cut -d ' ' -f 1 + else + shasum -a 256 | cut -d ' ' -f 1 + fi +} + +# release:place <source> <path> [<base-source>] writes the release's content to <path>. It merges a .json file, +# writes the region for a region source, and otherwise copies the file, keeping the source's mode. +release:place() { + local src=$1 path=$2 base=${3:-/dev/null} + case "$(release:kind "${src}" "${path}")" in + (merge) + release:json-write "${path}" 'merge(.; $base[0]; $new[0])' --slurpfile new "${src}" --slurpfile base "${base}" + ;; + (region) + release:region-write "${path}" "${src}" + ;; + (*) + path=$(release:target "${path}") + mkdir -p "$(dirname "${path}")" + cp "${src}" "${path}.kit-new" + mv "${path}.kit-new" "${path}" + esac +} + +# release:installed <source> <path> prints the repository's copy of the release's content at <path>: the file, +# or the region's content. It prints nothing and fails when there is none. +release:installed() { + local src=$1 path=$2 + case "$(release:kind "${src}" "${path}")" in + (region) release:region-read "${path}" ;; + (*) [[ -f "${path}" ]] && cat "${path}" ;; + esac +} + +# release:holds <source> <path> succeeds when the repository has the release's content at <path>: the same file, +# every merged entry, or the same region. +release:holds() { + local src=$1 path=$2 + case "$(release:kind "${src}" "${path}")" in + (merge) [[ -f "${path}" ]] && release:json-holds "${path}" "${src}" ;; + (*) cmp --silent "${src}" <(release:installed "${src}" "${path}") ;; + esac +} + +# release:place-new <source> <path> [<label>] places content the repository has not recorded. It writes the +# content when: +# +# - nothing is at <path> +# - the destination is a .json file to merge into +# - the file exists but has no region yet, for a region source +# +# When the same content is already there, it reports that and writes nothing. A region already there is +# regenerated. A different file fails, and is named and left as it is. +release:place-new() { + local src=$1 path=$2 label=${3:-} kind + kind=$(release:kind "${src}" "${path}") + if [[ -e "${path}" && ! -f "${path}" ]] + then + error "${path}: a directory, or something that is not a file, is there; move it aside" + return 1 + fi + if [[ "${kind}" == merge || ! -f "${path}" ]] \ + || { [[ "${kind}" == region ]] && ! release:installed "${src}" "${path}" >/dev/null; } + then + step "${path}${label:+: ${label}}" + release:place "${src}" "${path}" + elif release:holds "${src}" "${path}" + then + step "${path}${label:+: ${label}}, already as the release has it" + elif [[ "${kind}" == region ]] + then + step "${path}${label:+: ${label}}, the region regenerated" + release:place "${src}" "${path}" + else + error "${path}: a different file is there already; move it aside, or remove it if it is not yours" + return 1 + fi +} + +# release:place-all <root> <base> <module>… places every path the modules deliver under the base. A path where a +# different file exists fails. It returns the number of failures. +release:place-all() { + local root=$1 base=$2 path src refused=0 + shift 2 + while IFS=$'\t' read -r path src + do + [[ -n "${path}" ]] || continue + release:place-new "${src}" "$(release:dest "${base}" "${path}")" || refused=$(( refused + 1 )) + done < <(release:delivered "${root}" "${base}" "$@") + return "${refused}" +} + +# release:unplace <source> <path> removes the release's content from <path>: the kit's entries from a .json +# file, the kit's region from a region file, and otherwise the file itself. +release:unplace() { + local src=$1 path=$2 + case "$(release:kind "${src}" "${path}")" in + (merge) + [[ -f "${path}" ]] || return 0 + step "${path}: the kit's entries taken out" + release:json-write "${path}" 'unmerge(.; $base[0])' --slurpfile base "${src}" + ;; + (region) + step "${path}: the kit's region taken out" + release:region-remove "${path}" + ;; + (*) + step "removing ${path}" + rm -f "${path}" + esac +} + +# release:retire <base-root> <base> <previous> <current> <module>… handles the paths that the base's manifest +# records and the current release no longer delivers. <base-root> is the base release (the previous version), or +# empty when it is not available. For each such path that still exists: +# +# - a merged .json file: the previous release's entries are removed when the base release is available; +# otherwise it warns and leaves the file as it is +# - any other file: it is removed when its content still matches the digest the manifest records; otherwise it +# warns that the file was edited and leaves it as it is +# +# Under home, whose manifest every repository shares, it touches only paths the previous release delivered for +# these modules, and nothing at all without the base release. +release:retire() { + local base_root=$1 base=$2 previous=$3 current=$4 path dest previous_src candidates + shift 4 + candidates=$(LC_ALL=C comm -23 <(release:manifest-paths "${base}" | LC_ALL=C sort) \ + <(release:delivered "${kit_root}" "${base}" "$@" | cut -f 1 | LC_ALL=C sort)) + if [[ "${base}" != repo ]] + then + [[ -n "${base_root}" ]] || return 0 + candidates=$(LC_ALL=C comm -12 <(printf '%s\n' "${candidates}" | LC_ALL=C sort) \ + <(release:delivered "${base_root}" "${base}" "$@" | cut -f 1 | LC_ALL=C sort)) + fi + while IFS= read -r path + do + [[ -n "${path}" ]] || continue + dest=$(release:dest "${base}" "${path}") + [[ -e "${dest}" ]] || continue + previous_src="" + [[ -n "${base_root}" ]] && previous_src=$(release:source-of "${base_root}" "${base}" "${path}" "$@") + if release:merged "${dest}" + then + if [[ -n "${previous_src}" ]] + then + step "${dest}: retired in ${current}, the previous release's entries taken out" + release:json-write "${dest}" 'unmerge(.; $base[0])' --slurpfile base "${previous_src}" + else + warn "${dest}: retired in ${current}; without the base release its entries stay, and it is left as it was" + fi + elif [[ "$(release:recorded "${base}" "${path}")" \ + == "$(release:installed "${previous_src:-/dev/null}" "${dest}" | release:digest)" ]] + then + step "${dest}: retired in ${current}, removed" + release:unplace "${previous_src:-/dev/null}" "${dest}" + else + warn "${dest}: retired in ${current}, but edited since ${previous}; it is left as it was" + fi + done <<<"${candidates}" +} + +# release:write-manifest <base> <module>… writes the base's manifest: the version, the active modules, and the +# digest of the release's file for every path delivered under the base. The repository's manifest is always +# written. Home's manifest is shared by every repository on the machine. It is written when these modules deliver +# to home. When they do not, it drops the lines whose paths no longer exist, and removes the file once no line +# is left. +release:write-manifest() { + local base=$1 manifest path src delivered kept + shift + manifest=$(release:manifest "${base}") + delivered=$(release:delivered "${kit_root}" "${base}" "$@") + if [[ "${base}" != repo && -z "${delivered}" ]] + then + [[ -f "${manifest}" ]] || return 0 + kept=$(while IFS= read -r path + do + [[ -n "${path}" && -e "$(release:dest "${base}" "${path}")" ]] && grep -F " ${path}" "${manifest}" + done < <(release:manifest-paths "${base}"); true) + if [[ -z "${kept}" ]] + then + rm -f "${manifest}" + else + { sed -n '1,3p' "${manifest}"; printf '%s\n' "${kept}"; } > "${manifest}.kit-new" + mv "${manifest}.kit-new" "${manifest}" + fi + return 0 + fi + mkdir -p "$(dirname "${manifest}")" + { + printf '# .kit/manifest - written by kit, not by hand\n' + printf 'version %s\n' "$(release:version)" + if (( $# > 0 )) + then printf 'modules %s\n' "$*" + else printf 'modules\n' + fi + while IFS=$'\t' read -r path src + do + # Use if, not &&: as the last command in the loop body, a false && list would end the script under errexit. + if [[ -n "${path}" ]] + then + printf '%s %s\n' "$(release:digest < "${src}")" "${path}" + fi + done <<<"${delivered}" + } > "${manifest}" +} + +# release:write-manifests <module>… writes every base's manifest. +release:write-manifests() { + local base + for base in $(release:bases) + do + release:write-manifest "${base}" "$@" + done +} + +# release:manifest-version prints the version the repository's manifest records. +release:manifest-version() { sed -n 's/^version //p' .kit/manifest; } + +# release:manifest-modules prints the active modules the repository's manifest records, one per line. +release:manifest-modules() { sed -n 's/^modules *//p' .kit/manifest | tr ' ' '\n' | awk 'NF'; } + +# release:manifest-paths <base> prints every path the base's manifest records. A record line is a 64-character +# hex digest, two spaces, and the path, which can contain spaces. +release:manifest-paths() { + local manifest + manifest=$(release:manifest "$1") + [[ -f "${manifest}" ]] || return 0 + awk 'length($1) == 64 && $0 ~ /^[0-9a-f]+ / { print substr($0, 67) }' "${manifest}" +} + +# release:recorded <base> <path> prints the digest the base's manifest records for <path>, or nothing. +release:recorded() { + local manifest + manifest=$(release:manifest "$1") + [[ -f "${manifest}" ]] || return 0 + awk -v p="$2" 'length($1) == 64 && substr($0, 67) == p { print $1 }' "${manifest}" +} + +# release:require-manifest fails in a repository where the kit was never installed. +release:require-manifest() { + if [[ ! -f .kit/manifest ]] + then + error "run: kit install (this repository carries no .kit/manifest)" + return 1 + fi +} + +# release:finish <module>… reports a finished install or update. +release:finish() { + ok "kit $(release:version) is installed, modules: ${*:-none}; .kit/manifest is written." + release:say-unmet "$@" + step "Commit the delivered files. The workflow is in .kit/1st.md." +} + +# release:say-unmet <module>… warns about each unmet expectation of the modules, as a step left to do. +release:say-unmet() { + local line + while IFS= read -r line + do + [[ -n "${line}" ]] && warn "left to do in ${line}" + done < <(release:unmet "${kit_root}" "$@") + return 0 +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/status.sh b/bin/status.sh new file mode 100644 index 0000000..ff5e259 --- /dev/null +++ b/bin/status.sh @@ -0,0 +1,600 @@ +# shellcheck shell=bash +# status.sh - shows the checkouts and the open pull requests in progress (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# status:render [--no-fetch] [--json] [--mine | --all | --owner=<login>…] prints the checkouts and the open pull +# requests. It changes nothing. +# +# It calls the forge (GitHub or GitLab) once, for every open pull request with the issues it closes, its review +# threads, and the checks on its head commit. Everything else is read from local git. The forge call starts +# first, in the background, so it runs while the fetch runs and the checkouts are drawn. +# +# The output, in order: +# +# 1. what the fetch brought in, unless --no-fetch +# 2. the root checkout (the desk) +# 3. each worktree under worktrees/, with its uncommitted paths and then its commits +# 4. each open pull request, with its author, state, age, checks, the worktree that has its branch checked out, +# and the issues it closes +# 5. the branches whose upstream is gone +# +# Which pull requests are shown: +# +# --all every open pull request (the default) +# --mine the pull requests the caller opened, without the author column +# --owner=<login> the pull requests the named author opened; repeat the flag for more authors +# +# Every choice except --mine shows the author before the state. +# +# A pull request's state is the first of these that applies. Its color shows the state's family, never the +# single state: green when nothing is left to decide, yellow when a human owes a decision, and blue when the next +# move is a machine's or a reviewer's. Detail is dim. +# +# state glyph color +# draft 🔨 dim +# queued 🚦 blue +# conflicted 💥 yellow +# failing:gate 🔥 yellow +# no-check 📭 blue +# comments:open 💬 yellow +# blocked:approval 👀 blue +# ready 🛬 green +# +# --json prints the same data as one JSON document, {desk, worktrees, pullRequests, goneBranches}, with the pull +# requests chosen the same way. + +use git + +# status_commit_cap is how many uncommitted paths or commits a checkout block lists before it counts the rest. +status_commit_cap=10 + +# status_sep separates the cells of every row kit status prints. +status_sep=' · ' + +# status:width prints the terminal's width in columns: COLUMNS, else the width of the terminal on stderr, else +# 100. A width under 60 also gives 100. +status:width() { + local cols='' + if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] + then + cols=$(( 10#${COLUMNS} )) + elif [[ -t 2 ]] + then + cols=$(stty size <&2 2>/dev/null | cut -d' ' -f2 || true) + fi + if [[ "${cols}" =~ ^[0-9]+$ ]] && (( cols >= 60 )) + then + printf '%s\n' "${cols}" + else + printf '100\n' + fi +} + +# status:cell <text> <width> prints <text> padded to <width> characters, or clipped to it with an ellipsis. +status:cell() { + local text=$1 width=$2 + if (( ${#text} > width )) + then + printf '%s…' "${text:0:width-1}" + else + printf '%s%*s' "${text}" $(( width - ${#text} )) '' + fi +} + +# status:paint <sgr> <text> prints <text> in the color when stderr is a terminal. +status:paint() { + printf '%s%s%s' "$(out:sgr "$1")" "$2" "$(out:sgr 0)" +} + +# status:state-sgr <state> prints the color code of a state's family. +status:state-sgr() { + case "$1" in + (ready) printf '32' ;; + (conflicted | failing:gate | comments:open) printf '33' ;; + (queued | no-check | blocked:approval) printf '34' ;; + (*) printf '90' ;; + esac +} + +# status:state-emoji <state> prints a state's glyph. Each glyph is one code point drawn two columns wide, so the +# next column stays aligned. +status:state-emoji() { + case "$1" in + (draft) printf '🔨' ;; + (queued) printf '🚦' ;; + (conflicted) printf '💥' ;; + (failing:gate) printf '🔥' ;; + (no-check) printf '📭' ;; + (comments:open) printf '💬' ;; + (blocked:approval) printf '👀' ;; + (ready) printf '🛬' ;; + (*) printf '🔳' ;; + esac +} + +# status:age <seconds> prints a duration in its coarsest informative unit: 45s, 12m, 3h, 47d. +status:age() { + local secs=$1 + (( secs < 0 )) && secs=0 + if (( secs < 120 )) + then printf '%ds' "${secs}" + elif (( secs < 7200 )) + then printf '%dm' $(( secs / 60 )) + elif (( secs < 172800 )) + then printf '%dh' $(( secs / 3600 )) + else printf '%dd' $(( secs / 86400 )) + fi +} + +# status:refs prints "<ref> <sha>" for origin's branches and for the tags: the refs a fetch moves. +status:refs() { + git for-each-ref --format='%(refname) %(objectname)' refs/remotes/origin refs/tags | grep --invert-match '/HEAD ' + return 0 +} + +# status:plural <count> <singular> <plural> prints "<count> <singular>" or "<count> <plural>". +status:plural() { + if (( $1 == 1 )) + then printf '1 %s' "$2" + else printf '%s %s' "$1" "$3" + fi +} + +# status:fetch-summary <before> <after> <default-branch> prints one line listing what changed between two +# status:refs snapshots: new commits on origin's default branch, then branches updated, new, and pruned, then new +# tags. It prints "nothing new" when nothing changed. +status:fetch-summary() { + local default_ref="refs/remotes/origin/$3" kind ref old new commits=0 updated=0 added=0 pruned=0 tags=0 line part + local -a parts=() + while IFS=' ' read -r kind ref old new + do + case "${kind}:${ref}" in + (moved:"${default_ref}") commits=$(git rev-list --count "${old}..${new}" 2>/dev/null || printf 0) ;; + (moved:refs/remotes/*) updated=$(( updated + 1 )) ;; + (new:refs/remotes/*) added=$(( added + 1 )) ;; + (gone:refs/remotes/*) pruned=$(( pruned + 1 )) ;; + (new:refs/tags/*) tags=$(( tags + 1 )) ;; + esac + done < <(awk 'NR == FNR { old[$1] = $2; next } + !($1 in old) { print "new", $1, "-", $2; next } + old[$1] != $2 { print "moved", $1, old[$1], $2 } + { delete old[$1] } + END { for (ref in old) print "gone", ref, old[ref], "-" }' \ + <(printf '%s\n' "$1" | grep .) <(printf '%s\n' "$2" | grep .)) + (( commits > 0 )) && parts+=("$(status:plural "${commits}" 'new commit' 'new commits') on $3") + (( updated > 0 )) && parts+=("$(status:plural "${updated}" 'branch updated' 'branches updated')") + (( added > 0 )) && parts+=("$(status:plural "${added}" 'new branch' 'new branches')") + (( pruned > 0 )) && parts+=("$(status:plural "${pruned}" 'branch pruned' 'branches pruned')") + (( tags > 0 )) && parts+=("$(status:plural "${tags}" 'new tag' 'new tags')") + (( ${#parts[@]} > 0 )) || parts=('nothing new') + line=${parts[0]} + for part in "${parts[@]:1}" + do + line+="${status_sep}${part}" + done + printf '📥 fetched origin: %s\n' "${line}" +} + +# status:fetch <default-branch> fetches origin with --prune, then prints status:fetch-summary's line and a blank +# line. When the fetch fails, it prints a warning instead of the summary. +status:fetch() { + local before + before=$(status:refs) + if git fetch --quiet --prune origin + then + status:fetch-summary "${before}" "$(status:refs)" "$1" >&2 + else + warn "git fetch --prune failed; ahead and behind counts may be stale" + fi + printf '\n' >&2 +} + +# status:collect prints one JSON object per open pull request, as the forge provider builds them. When the forge +# cannot be reached, it prints nothing and a warning. +status:collect() { + use forge + forge:open-prs || warn "no pull request is shown; see above" +} + +# status:select <login>… reads pull request documents on stdin and keeps those opened by one of the named +# authors. With no login, it keeps every one. +status:select() { + if (( $# == 0 )) + then cat + else jq --compact-output --arg logins "$*" 'select(.author as $a | ($logins | split(" ")) | index($a))' + fi +} + +# status:fork <branch> <default-branch> prints the commit where <branch> left the default branch, or nothing. +status:fork() { + git merge-base "origin/$2" "$1" 2>/dev/null || git merge-base "$2" "$1" 2>/dev/null || true +} + +# status:relative <path> prints <path> relative to the directory the command was run from: ".", a path down, or +# a path up through ../. Both paths are resolved physically (the target through its nearest existing directory), +# so a symlinked directory on either side still gives the right ../ path. +status:relative() { + local target=$1 here=${kit_invoked_in:-$(pwd -P)} up='' dir rest='' + dir=${target} + while [[ -n "${dir}" && ! -d "${dir}" ]] + do + rest="${dir##*/}${rest:+/${rest}}" + dir=${dir%/*} + done + dir=$(cd "${dir:-/}" && pwd -P) + target="${dir}${rest:+/${rest}}" + if [[ "${target}" == "${here}" ]] + then + printf '.\n' + return 0 + fi + while [[ -n "${here}" && "${target}" != "${here}" && "${target}" != "${here}"/* ]] + do + here=${here%/*} + up+='../' + done + if [[ "${target}" == "${here}" ]] + then + printf '%s\n' "${up%/}" + else + printf '%s%s\n' "${up}" "${target#"${here}"/}" + fi +} + +# status:checkout <path> <branch> <default-branch> prints one JSON object that measures the checkout at <path>. +# The fork point is where the branch left origin's default branch. The fields: +# +# path, relative the checkout's path, and that path relative to the caller's directory +# branch, detached the branch (empty when detached), and whether the checkout is detached +# ahead, behind the commits on the branch past the fork point, and on origin's default branch past it +# added, removed the lines the working tree adds and removes against the fork point +# uncommitted the number of uncommitted paths +# changes each uncommitted path as {status, path}, with git's two-letter status +# commits the commits past the fork point, as [{sha, subject}] +# +# A detached checkout measures its lines against its own HEAD and lists no commits. +status:checkout() { + local path=$1 branch=$2 default_branch=$3 fork='' ahead=0 behind=0 added=0 removed=0 changes a r _ + [[ -n "${branch}" ]] && fork=$(status:fork "${branch}" "${default_branch}") + if [[ -n "${fork}" ]] + then + ahead=$(git rev-list --count "${fork}..${branch}" 2>/dev/null || printf 0) + behind=$(git rev-list --count "${fork}..origin/${default_branch}" 2>/dev/null || printf 0) + fi + while IFS=$'\t' read -r a r _ + do + [[ -n "${a}" && "${a}" != '-' ]] && added=$(( added + a )) + [[ -n "${r}" && "${r}" != '-' ]] && removed=$(( removed + r )) + done < <(git -C "${path}" diff --numstat "${fork:-HEAD}" 2>/dev/null || true) + changes=$(git -C "${path}" status --porcelain 2>/dev/null || true) + { + [[ -n "${fork}" ]] && git -C "${path}" log --format='%h%x09%s' "${fork}..${branch}" 2>/dev/null + true + } | jq --raw-input --slurp --compact-output \ + --arg path "${path}" --arg relative "$(status:relative "${path}")" --arg branch "${branch}" \ + --argjson ahead "${ahead}" --argjson behind "${behind}" --argjson added "${added}" --argjson removed "${removed}" \ + --arg changes "${changes}" ' + [$changes | split("\n")[] | select(. != "") | { status: .[0:2], path: .[3:] }] as $changes + | { path: $path, relative: $relative, branch: $branch, detached: ($branch == ""), + ahead: $ahead, behind: $behind, added: $added, removed: $removed, uncommitted: ($changes | length), + changes: $changes, + commits: [split("\n")[] | select(. != "") | split("\t") | { sha: .[0], subject: (.[1:] | join("\t")) }] }' +} + +# status:label <relative> <branch> prints a checkout's label and its plain width, tab-separated. The label is: +# +# - for a detached checkout: its path +# - at ".": the branch alone +# - when the path's last component is the branch or ends in "-<branch>": the path, with the directory dim and +# the branch underlined +# - otherwise: "<branch> on <path>" +status:label() { + local relative=$1 branch=$2 dir + if [[ -z "${branch}" ]] + then + printf '%s\t%s\n' "${relative}" "${#relative}" + elif [[ "${relative}" == "." ]] + then + printf '%s\t%s\n' "${branch}" "${#branch}" + elif [[ "${relative##*/}" == "${branch}" || "${relative##*/}" == *"-${branch}" ]] + then + dir=${relative%"${branch}"} + printf '%s%s\t%s\n' "$(status:paint 90 "${dir}")" "$(status:paint 4 "${branch}")" "${#relative}" + else + printf '%s %s %s\t%s\n' "${branch}" "$(status:paint 90 on)" "${relative}" $(( ${#branch} + 4 + ${#relative} )) + fi +} + +# status:paint-count <count> <sign> <sgr> <width> <side> prints <sign><count> padded to <width> characters, +# aligned to <side> (left or right). The text is in <sgr> above zero and dim at zero. It pads by characters, +# because the arrows are multibyte and printf's field width counts bytes. +status:paint-count() { + local count=$1 sign=$2 sgr=$3 width=$4 side=$5 text pad='' + (( count > 0 )) || sgr=90 + text="${sign}${count}" + (( ${#text} < width )) && printf -v pad '%*s' $(( width - ${#text} )) '' + if [[ "${side}" == right ]] + then + printf '%s%s' "${pad}" "$(status:paint "${sgr}" "${text}")" + else + printf '%s%s' "$(status:paint "${sgr}" "${text}")" "${pad}" + fi +} + +# status:measures <checkout-json> [<widths>] prints "↑a ↓b · +x -y", then " · n uncommitted" in yellow when n is +# above zero. <widths> is status:count-widths' line. With it, ahead and added are right-aligned, and behind and +# removed left-aligned, in columns of those widths, so the dots line up across rows. +status:measures() { + local ahead behind added removed uncommitted ahead_w=0 behind_w=0 added_w=0 removed_w=0 + IFS=$'\t' read -r ahead behind added removed uncommitted \ + < <(jq --raw-output '[.ahead, .behind, .added, .removed, .uncommitted] | @tsv' <<<"$1") + [[ -n "${2:-}" ]] && IFS=' ' read -r ahead_w behind_w added_w removed_w <<<"$2" + # The last column is padded only when something follows it, so no row ends in trailing spaces. + (( uncommitted > 0 )) || removed_w=0 + printf '%s %s%s%s %s' \ + "$(status:paint-count "${ahead}" '↑' '1;32' "${ahead_w}" right)" \ + "$(status:paint-count "${behind}" '↓' '1;31' "${behind_w}" left)" \ + "${status_sep}" \ + "$(status:paint-count "${added}" '+' '1;32' "${added_w}" right)" \ + "$(status:paint-count "${removed}" '-' '1;31' "${removed_w}" left)" + (( uncommitted > 0 )) && printf '%s%s' "${status_sep}" "$(status:paint 33 "${uncommitted} uncommitted")" + return 0 +} + +# status:count-widths <checkout-json>… prints the widest "↑a", "↓b", "+x", and "-y" across the checkouts, as four +# numbers on one line. +status:count-widths() { + printf '%s\n' "$@" | jq --raw-output --slurp ' + [ (map(.ahead) | max), (map(.behind) | max), (map(.added) | max), (map(.removed) | max) ] + | map(tostring | length + 1) | join(" ")' +} + +# status:checkout-block <glyph> <checkout-json> <label-width> [<widths>] prints a checkout: +# +# 1. its row, with the counts in status:count-widths' columns +# 2. its uncommitted paths, each with its status in yellow +# 3. its commits, each with its hash dim +# +# Past status_commit_cap, it prints the first paths and a count of the rest. For commits, it prints the newest +# ones, a dim count of the hidden ones, and the oldest one. +status:checkout-block() { + local glyph=$1 doc=$2 label_w=$3 widths=${4:-} relative branch label width mark='' sha subject code path + IFS=$'\x1f' read -r relative branch < <(jq --raw-output '[.relative, .branch] | join("\u001f")' <<<"${doc}") + IFS=$'\t' read -r label width < <(status:label "${relative}" "${branch}") + [[ -n "${branch}" ]] || mark="${status_sep}$(status:paint 33 '❗ detached HEAD')" + printf '%s %s%*s%s%s%s\n' "${glyph}" "${label}" $(( label_w - width )) '' "${mark}" "${status_sep}" \ + "$(status:measures "${doc}" "${widths}")" >&2 + # A status can start with a space, which a tab-separated read would drop, so the separator is \x1f instead. + while IFS=$'\x1f' read -r code path + do + [[ -n "${path}" ]] || continue + if [[ "${code}" == '…' ]] + then + printf ' %s\n' "$(status:paint 90 "… ${path}")" >&2 + else + printf ' %s %s\n' "$(status:paint 33 "${code}")" "${path}" >&2 + fi + done < <(jq --raw-output --argjson cap "${status_commit_cap}" ' + (.changes // []) as $c | ($c | length) as $n + | (if $n > $cap + 1 + then ($c[:$cap][] | [.status, .path]), ["…", "and \($n - $cap) more"] + else $c[] | [.status, .path] end) + | join("\u001f")' <<<"${doc}") + while IFS=$'\t' read -r sha subject + do + [[ -n "${sha}" ]] || continue + if [[ "${sha}" == '…' ]] + then + printf ' %s\n' "$(status:paint 90 "… ${subject}")" >&2 + else + printf ' %s %s\n' "$(status:paint 90 "${sha}")" "${subject}" >&2 + fi + done < <(jq --raw-output --argjson cap "${status_commit_cap}" ' + (.commits | length) as $n + | (if $n > $cap + 1 + then (.commits[:$cap - 1][] | [.sha, .subject]), ["…", "and \($n - $cap) more"], + (.commits[-1] | [.sha, .subject]) + else .commits[] | [.sha, .subject] end) + | @tsv' <<<"${doc}") +} + +# status:check-cell <checks-json> prints the checks cell, with the duration dim: +# +# - passed: "✅ <passed> / <total> <took>" +# - failed: "❌ <failing> failing <passed> / <total> <took>" +# - running: "🏃 <passed> / <total> <elapsed>" +# +# It prints nothing when no check has reported. +status:check-cell() { + local state passed total failing took text + # A field can be empty, and a tab-separated read collapses an empty field, so the separator is \x1f instead. + IFS=$'\x1f' read -r state passed total failing took \ + < <(jq --raw-output '[.state, .passed, .total, .failing, (.took // "")] | map(tostring) | join("\u001f")' <<<"$1") + case "${state}" in + (SUCCESS) text="✅ ${total} / ${total}" ;; + (FAILURE) text="❌ ${failing} failing ${passed} / ${total}" ;; + (PENDING) text="🏃 ${passed} / ${total}" ;; + (*) return 0 ;; + esac + printf '%s' "${text}" + [[ -n "${took}" ]] && printf ' %s' "$(status:paint 90 "$(status:age "${took}")")" + return 0 +} + +# status:pr-block <pr-json> <title-width> <owner-width> <state-width> <age-width> <worktree> prints a pull request: +# its row, then the issues it closes. The state and its age share a cell. The row shows the author before the state +# when <owner-width> is above zero, and ends with the branch when a worktree here has it checked out. +status:pr-block() { + local doc=$1 title_w=$2 owner_w=$3 state_w=$4 age_w=$5 worktree=$6 + local number ref title author branch state age unresolved row issue checks + IFS=$'\x1f' read -r number ref title author branch state age unresolved \ + < <(jq --raw-output '[.number, (.ref // "#\(.number)"), .title, .author, .branch, .state, .age, .unresolved] + | map(tostring) + | join("\u001f")' <<<"${doc}") + row="🔀 $(status:cell "${ref} ${title}" "${title_w}")" + (( owner_w > 0 )) && row+="${status_sep}👤 $(status:paint 90 "$(status:cell "${author}" "${owner_w}")")" + row+="${status_sep}$(status:state-emoji "${state}")" + row+=" $(status:paint "$(status:state-sgr "${state}")" "$(status:cell "${state}" "${state_w}")")" + row+=" $(status:paint 90 "$(status:cell "$(status:age "${age}")" "${age_w}")")" + checks=$(status:check-cell "$(jq --compact-output .checks <<<"${doc}")") + [[ -n "${checks}" ]] && row+="${status_sep}${checks}" + (( unresolved > 0 )) && row+="${status_sep}$(status:paint 33 "💬 ${unresolved} unresolved")" + [[ -n "${worktree}" ]] && row+="${status_sep}$(status:paint 90 "🌿 ${branch}")" + printf '%s\n' "${row}" >&2 + while IFS= read -r issue + do + [[ -n "${issue}" ]] && printf ' %s\n' "$(status:paint 90 "closes ${issue}")" >&2 + done < <(jq --raw-output '.closes[] | "#\(.number) \(.title)"' <<<"${doc}") + return 0 +} + +# status:render [--no-fetch] [--json] [--mine | --all | --owner=<login>…] prints the output the file header +# describes, or the same data as one JSON document. +status:render() { + local no_fetch=false json=false choice=all arg main_root default_branch desk_branch path branch doc gone + local -a owners=() + for arg in "$@" + do + case "${arg}" in + (--no-fetch) no_fetch=true ;; + (--json) json=true ;; + (--mine) choice=mine ;; + (--all) choice=all ;; + (--owner=?*) choice=owner; owners+=("${arg#--owner=}") ;; + (*) + error "unknown flag '${arg}' (usage: kit status [--no-fetch] [--json] [--mine | --all | --owner=<login>…])" + return 2 + esac + done + # The forge call is the slow one and needs nothing local, so it starts first and runs during the fetch and the + # local reads. Its warnings are saved and printed where the pull requests appear, so they do not land among the + # checkouts. With --mine, the same background job also asks the forge for the caller's login. + local scratch prs_job prs_failed=false + scratch=$(mktemp -d "${TMPDIR:-/tmp}/kit-status.XXXXXX") + # shellcheck disable=SC2064 # the path is fixed now; the trap outlives this function's locals + trap "rm -rf '${scratch}'" EXIT + ( + if [[ "${choice}" == mine ]] + then + use forge + owners=("$(forge:me)") || exit 1 + fi + status:collect | status:select "${owners[@]}" + ) > "${scratch}/prs" 2> "${scratch}/prs.err" & + prs_job=$! + + main_root=$(git:main-root) + default_branch=$(git:default-branch) + if [[ "${no_fetch}" == true ]] + then + true + elif [[ "${json}" == true ]] + then + status:fetch "${default_branch}" 2>/dev/null + else + status:fetch "${default_branch}" + fi + desk_branch=$(git -C "${main_root}" symbolic-ref --quiet --short HEAD 2>/dev/null || true) + gone=$(git:gone-branches) + + # Measure the desk, then the worktrees in issue-number order, each as JSON. + local desk prs + local -a worktrees=() + desk=$(status:checkout "${main_root}" "${desk_branch}" "${default_branch}") + while IFS=$'\t' read -r path _ branch + do + [[ -n "${path}" ]] || continue + worktrees+=("$(status:checkout "${path}" "${branch}" "${default_branch}")") + done < <(git:worktrees | status:by-number) + + if [[ "${json}" == true ]] + then + wait "${prs_job}" || prs_failed=true + cat "${scratch}/prs.err" >&2 + [[ "${prs_failed}" == false ]] || return 1 + prs=$(<"${scratch}/prs") + jq --null-input --argjson desk "${desk}" \ + --argjson worktrees "$(printf '%s\n' "${worktrees[@]}" | jq --slurp .)" \ + --argjson pullRequests "$(jq --slurp . <<<"${prs}")" \ + --argjson goneBranches "$(jq --raw-input . <<<"${gone}" | jq --slurp 'map(select(. != ""))')" \ + '{ desk: $desk, worktrees: $worktrees, pullRequests: $pullRequests, goneBranches: $goneBranches }' + return 0 + fi + + # Draw the checkouts while the forge call may still be running. + local label_w=0 width relative widths + local -A worktree_on=() + for doc in "${desk}" "${worktrees[@]}" + do + IFS=$'\x1f' read -r relative branch < <(jq --raw-output '[.relative, .branch] | join("\u001f")' <<<"${doc}") + IFS=$'\t' read -r _ width < <(status:label "${relative}" "${branch}") + (( width > label_w )) && label_w=${width} + [[ -n "${branch}" && "${doc}" != "${desk}" ]] && worktree_on[${branch}]=${relative} + done + widths=$(status:count-widths "${desk}" "${worktrees[@]}") + status:checkout-block '🏠' "${desk}" "${label_w}" "${widths}" + for doc in "${worktrees[@]}" + do + printf '\n' >&2 + status:checkout-block '🌿' "${doc}" "${label_w}" "${widths}" + done + + wait "${prs_job}" || prs_failed=true + if [[ -s "${scratch}/prs.err" ]] + then + printf '\n' >&2 + cat "${scratch}/prs.err" >&2 + fi + [[ "${prs_failed}" == false ]] || return 1 + prs=$(<"${scratch}/prs") + + # Compute the column widths across the pull requests. The author column is shown unless --mine is given. + local title_w=0 owner_w=0 state_w=0 age_w=0 text author state_text age_secs + while IFS=$'\x1f' read -r text author state_text age_secs + do + [[ -n "${text}" ]] || continue + (( ${#text} > title_w )) && title_w=${#text} + [[ "${choice}" != mine ]] && (( ${#author} > owner_w )) && owner_w=${#author} + (( ${#state_text} > state_w )) && state_w=${#state_text} + text=$(status:age "${age_secs}") + (( ${#text} > age_w )) && age_w=${#text} + done < <(jq --raw-output '["\(.ref // "#\(.number)") \(.title)", .author, .state, .age] | map(tostring) + | join("\u001f")' \ + <<<"${prs}") + width=$(( $(status:width) - 3 - 3 - 3 - state_w - 3 - age_w - 3 - 24 - (owner_w > 0 ? owner_w + 6 : 0) )) + (( width < 20 )) && width=20 + (( title_w > width )) && title_w=${width} + + while IFS= read -r doc + do + [[ -n "${doc}" ]] || continue + printf '\n' >&2 + branch=$(jq --raw-output .branch <<<"${doc}") + status:pr-block "${doc}" "${title_w}" "${owner_w}" "${state_w}" "${age_w}" "${worktree_on[${branch}]:-}" + done <<<"${prs}" + if [[ -n "${gone}" ]] + then + printf '\n' >&2 + while IFS= read -r branch + do + printf '🔖 %s%s%s\n' "${branch}" "${status_sep}" \ + "$(status:paint 90 "upstream gone${status_sep}kit tidy removes it")" >&2 + done <<<"${gone}" + fi +} + +# status:by-number sorts git:worktrees lines by the issue number their directory name starts with. A directory +# name that does not start with a number sorts first. +status:by-number() { + local path rest dir number + while IFS=$'\t' read -r path rest + do + [[ -n "${path}" ]] || continue + dir=${path##*/} + number=${dir%%-*} + [[ "${number}" =~ ^[0-9]+$ ]] || number=0 + printf '%s\t%s\t%s\n' "${number}" "${path}" "${rest}" + done | sort -n -k1,1 | cut -f2- +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/template.sh b/bin/template.sh new file mode 100644 index 0000000..5e8acd6 --- /dev/null +++ b/bin/template.sh @@ -0,0 +1,228 @@ +# shellcheck shell=bash +# template.sh - a rule's templates: make a new file from one, and check a file still matches one (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# A rule can have templates, one per variant of the file it governs, at .kit/templates/<rule>/<variant>. The rule's +# frontmatter lists them in the order they are tried. Each entry can limit its template to a path glob, and can +# mark the file as executable: +# +# templates: +# - variant: root +# path: justfile +# - variant: module +# +# A template is the file itself, with holes written «name». There are two kinds of hole: +# +# a hole within a line making a file fills it with a value. Checking a file matches it against any text on +# that line. A hole used twice must match the same text each time. +# a hole that is a line making a file fills it with the value's lines, else one blank line. Checking a file +# matches it against any run of lines, including none. +# +# The values of path, name, and stem come from the path. The caller gives the value of every other hole within a +# line. +# +# The same template both makes a file (kit new file) and checks one (kit lint). A file passes when it matches any +# variant its path allows. To change a template, a repository adds a module of its own that places the same path. +# The later module in dependency order wins. +# +# see: docs/templates.md + +use context yaml + +# template_dir is the directory the templates are installed in. +declare -gr template_dir=.kit/templates + +# template:exempt <path> is true for a path that no template checks: +# +# - a template itself +# - a module's region, which is a fragment of a file +# - a path .gitattributes marks linguist-generated, because a tool writes it +template:exempt() { + [[ "$1" =~ (^|/)\.kit/templates/ || "$1" =~ (^|/)modules/[^/]+/regions/ ]] && return 0 + [[ "$(git check-attr linguist-generated -- "$1" 2>/dev/null)" == *': linguist-generated: set' ]] +} + +# template:rule-name <rule-file> prints the rule's name: its file's name without .md. +template:rule-name() { + local name=${1##*/} + printf '%s\n' "${name%.md}" +} + +# template:variants <rule-file> prints "<variant>\x1f<path-glob>\x1f<executable>" for each template the rule lists, +# in order. +template:variants() { + yaml:records "$1" templates variant path executable +} + +# template:file <rule-file> <variant> prints the template's path. +template:file() { + printf '%s/%s/%s\n' "${template_dir}" "$(template:rule-name "$1")" "$2" +} + +# template:applies <rule-file> <path> prints the variants whose path glob matches <path>, or that have no glob. It +# prints them in order, in the same format as template:variants. +template:applies() { + local variant glob executable + while IFS=$'\x1f' read -r variant glob executable + do + [[ -n "${variant}" ]] || continue + if [[ -z "${glob}" ]] || context:glob-matches "$2" "${glob}" + then + printf '%s\x1f%s\x1f%s\n' "${variant}" "${glob}" "${executable}" + fi + done < <(template:variants "$1") +} + +# template:regex reads a template on stdin and prints the regular expression that matches a file with its shape, +# in Oniguruma syntax, which jq uses. +template:regex() { + jq --raw-input --slurp --raw-output ' + def literal: gsub("(?<c>[.^$|?*+()\\[\\]{}\\\\])"; "\\\(.c)"); + reduce (split("\n") | if .[-1] == "" then .[:-1] else . end)[] as $line + ({re: "", seen: {}}; + if ($line | test("^«[a-z_]+»$")) then .re += "(?:[^\\n]*\\n)*?" + else + ([$line | match("«([a-z_]+)»"; "g") | .captures[0].string]) as $names + | ([$line | splits("«[a-z_]+»")] | map(literal)) as $parts + | reduce range(0; $names | length) as $i + (.line = $parts[0]; + if .seen[$names[$i]] then .line += "\\k<\($names[$i])>" + else .line += "(?<\($names[$i])>[^\\n]*?)" | .seen[$names[$i]] = true end + | .line += $parts[$i + 1]) + | .re += .line + "\\n" | del(.line) + end) + | "\\A" + .re + "\\z"' +} + +# template:test <regex> <file> is true when the file matches template:regex's expression. +template:test() { + [[ "$(jq --raw-input --slurp --arg re "$1" 'test($re)' < "$2")" == true ]] +} + +# template:matches <template> <file> is true when the file matches the template. +template:matches() { + template:test "$(template:regex < "$1")" "$2" +} + +# template:diverges <template> <file> prints "<line>\t<text>" for the first template line the file does not match. +# It finds the longest run of the template's first lines that the file still starts with. +template:diverges() { + local total n + total=$(wc -l < "$1") + for (( n = 1; n <= total; n++ )) + do + if ! template:test "$({ head -n "${n}" "$1"; printf '«rest»\n'; } | template:regex)" "$2" + then + printf '%s\t%s\n' "${n}" "$(sed -n "${n}p" "$1")" + return 0 + fi + done +} + +# template:holes <template> prints the holes within a line (the ones a value must fill), once each, in order. +template:holes() { + grep --invert-match --extended-regexp '^«[a-z_]+»$' "$1" | grep --only-matching --extended-regexp '«[a-z_]+»' \ + | tr -d '«»' | awk '!seen[$0]++' + return 0 +} + +# template:render <template> <values-json> prints the template with every hole filled from the values object: +# +# - a hole within a line gets its value +# - a hole that is a line gets its value's lines, else one blank line. When it is the template's last line and +# has no value, it gets nothing, so the file never ends in a blank line. +template:render() { + jq --raw-input --slurp --join-output --argjson v "$2" ' + split("\n") | if .[-1] == "" then .[:-1] else . end + | length as $n + | to_entries + | map(.key as $i | .value + | if test("^«[a-z_]+»$") then ($v[ltrimstr("«") | rtrimstr("»")] // (if $i == $n - 1 then null else "" end)) + else gsub("«(?<k>[a-z_]+)»"; $v[.k] // "«\(.k)»") end + | select(. != null) | . + "\n") + | add // ""' < "$1" +} + +# template:rules <path> prints the rules that govern the path and have templates, most specific first. The most +# specific rule has the matching glob with the most non-wildcard characters. A rule matched only by the file's +# first line sorts last. +template:rules() { + local path=$1 rule glob best literal + while IFS= read -r rule + do + [[ -n "${rule}" && -n "$(template:variants "${rule}")" ]] || continue + best=0 + while IFS= read -r glob + do + if [[ -n "${glob}" ]] && context:glob-matches "${path}" "${glob}" + then + literal=${glob//\*/} + (( ${#literal} > best )) && best=${#literal} + fi + done < <(yaml:list "${rule}" paths) + printf '%s\t%s\n' "${best}" "${rule}" + done < <(context:rules "${path}") | LC_ALL=C sort -t $'\t' -k1,1nr -k2,2 | cut -f2 +} + +# template:check <rule-file> <path>… is true when every file matches a variant the rule allows at its path. For +# each file that matches none, it prints an error naming the variants it was checked against, and the line where +# the file stops matching the variant it follows furthest. A rule with no templates passes every file. Each +# template is compiled once, and all the files are read and tested in one pass, so checking every tracked file +# stays fast. +template:check() { + local rule=$1 path variant glob executable file line text best where failed=0 + shift + local -A regex=() allowed=() + while IFS=$'\x1f' read -r variant glob executable + do + [[ -n "${variant}" ]] || continue + file=$(template:file "${rule}" "${variant}") + if [[ ! -f "${file}" ]] + then + error "${rule} lists the template ${variant}, and ${file} is not here; run: kit update" + return 1 + fi + regex[${variant}]=$(template:regex < "${file}") + done < <(template:variants "${rule}") + (( ${#regex[@]} > 0 )) || return 0 + for path in "$@" + do + allowed[${path}]=$(template:applies "${rule}" "${path}" | cut -d $'\x1f' -f 1 | paste -s -d ' ' -) + done + while IFS=$'\t' read -r path variants + do + [[ -n "${path}" ]] || continue + failed=$(( failed + 1 )) + best=0 + where='the template at its end' + for variant in ${variants} + do + file=$(template:file "${rule}" "${variant}") + IFS=$'\t' read -r line text < <(template:diverges "${file}" "${path}") + if [[ -n "${line}" ]] && (( line > best )) + then + best=${line} + where="${file} line ${line}: ${text}" + fi + done + error "${path} does not have the shape of the $(template:rule-name "${rule}") template (${variants// /, });" \ + "it leaves ${where}" + done < <(jq --null-input --raw-output \ + --rawfile files <(for path in "$@"; do printf '%s\t%s\n' "${path}" "${allowed[${path}]}"; done) \ + --rawfile regexes <(for variant in "${!regex[@]}" + do + printf '%s\x1f%s\x1e' "${variant}" "${regex[${variant}]}" + done) \ + --rawfile contents <(for path in "$@"; do cat "${path}"; printf '\x1e'; done) ' + ($regexes | split("\u001e") | map(select(. != "") | split("\u001f") | {(.[0]): .[1]}) | add) as $regex + | ($contents | split("\u001e")) as $text + | ($files | split("\n") | map(select(. != "") | split("\t") | {path: .[0], variants: (.[1] | split(" "))})) + | to_entries[] + | .key as $i | .value + | select((.variants | length) > 0) + | select(any(.variants[]; . as $v | $text[$i] | test($regex[$v])) | not) + | "\(.path)\t\(.variants | join(" "))"') + (( failed == 0 )) +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/toolchain-brew.sh b/bin/toolchain-brew.sh new file mode 100644 index 0000000..ce3c38e --- /dev/null +++ b/bin/toolchain-brew.sh @@ -0,0 +1,69 @@ +# shellcheck shell=bash +# toolchain-brew.sh - the Homebrew toolchain provider: a Brewfile lists the formulae and casks (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/toolchain.sh loads this provider for `provider: brew`, or when a Brewfile is at the repository root. The +# `file:` field names a different file. +# +# A Brewfile pins a formula by name only, so its version moves with Homebrew unless the name includes it +# (node@22). The kit can only report which formulae are behind and upgrade them. +# +# The provider needs brew, on PATH or at one of Homebrew's own prefixes. That covers a Linux runner where brew is +# installed but not on PATH. + +toolchain-brew:file() { + local file + file=$(toolchain:field brew file) + printf '%s\n' "${file:-Brewfile}" +} + +# toolchain-brew:brew prints the brew executable, on PATH or at one of Homebrew's prefixes. +toolchain-brew:brew() { + local brew + for brew in "$(command -v brew 2>/dev/null)" /opt/homebrew/bin/brew /usr/local/bin/brew \ + /home/linuxbrew/.linuxbrew/bin/brew + do + [[ -n "${brew}" && -x "${brew}" ]] && { printf '%s\n' "${brew}"; return 0; } + done + error "install Homebrew: https://brew.sh (the brew toolchain needs it)" + return 1 +} + +toolchain-brew:require() { toolchain-brew:brew >/dev/null; } + +toolchain-brew:install() { + local brew + brew=$(toolchain-brew:brew) || return 1 + "${brew}" bundle install --file "$(toolchain-brew:file)" --no-upgrade --quiet +} + +# toolchain-brew:outdated prints the Brewfile's formulae and casks that brew reports as outdated. +toolchain-brew:outdated() { + local brew names doc + brew=$(toolchain-brew:brew) || return 1 + names=$("${brew}" bundle list --file "$(toolchain-brew:file)" --formula --cask 2>/dev/null) || return 1 + doc=$("${brew}" outdated --json=v2 2>/dev/null) || return 1 + jq --raw-output --arg names "${names}" ' + ($names | split("\n") | map(select(. != ""))) as $wanted + | (.formulae + .casks)[] + | select(.name as $n | $wanted | index($n)) + | "\(.name)\t\(.installed_versions | last)\t\(.current_version)"' <<<"${doc}" +} + +toolchain-brew:update() { + local brew outdated + brew=$(toolchain-brew:brew) || return 1 + outdated=$(toolchain-brew:outdated | cut -f 1) || return 1 + [[ -z "${outdated}" ]] && return 0 + # shellcheck disable=SC2086 # one formula or cask per word + "${brew}" upgrade ${outdated} +} + +toolchain-brew:path() { + local brew prefix + brew=$(toolchain-brew:brew) || return 1 + prefix=$("${brew}" --prefix) + printf '%s/bin\n%s/sbin\n' "${prefix}" "${prefix}" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/toolchain-docker.sh b/bin/toolchain-docker.sh new file mode 100644 index 0000000..ec7234b --- /dev/null +++ b/bin/toolchain-docker.sh @@ -0,0 +1,115 @@ +# shellcheck shell=bash +# toolchain-docker.sh - the container image toolchain provider: a Dockerfile installs the tools (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/toolchain.sh loads this provider for `provider: docker`, or when toolchain.Dockerfile is at the repository +# root. The `file:` field names a different file. The `image:` field names the image (default: kit-toolchain). +# +# The image is built from the file and tagged with the file's digest. A changed file builds a new image; an +# unchanged file is never rebuilt. A command runs in the container with the checkout mounted at /work, as the +# caller's user, so the files it writes belong to the caller. +# +# The file pins its base images: `FROM name:tag@sha256:<digest>`. outdated asks the registry for each tag's +# current digest and lists every base that has no pin or is pinned to an older digest. update rewrites the pins. +# The provider needs docker with buildx. + +toolchain-docker:file() { + local file + file=$(toolchain:field docker file) + printf '%s\n' "${file:-toolchain.Dockerfile}" +} + +toolchain-docker:require() { + command -v docker >/dev/null 2>&1 && return 0 + error "install Docker: https://docs.docker.com/get-docker/ (the docker toolchain needs it)" + return 1 +} + +# toolchain-docker:tag prints the image's tag: the image name, then the first twelve hex digits of the file's +# digest. +toolchain-docker:tag() { + local file name + file=$(toolchain-docker:file) + name=$(toolchain:field docker image) + if [[ ! -f "${file}" ]] + then + error "${file} is missing; the docker toolchain builds its image from it" + return 1 + fi + printf '%s:%s\n' "${name:-kit-toolchain}" "$(release:digest < "${file}" | cut -c 1-12)" +} + +toolchain-docker:install() { + local file tag + toolchain-docker:require || return 1 + file=$(toolchain-docker:file) + tag=$(toolchain-docker:tag) || return 1 + docker image inspect "${tag}" >/dev/null 2>&1 && return 0 + docker build --quiet --file "${file}" --tag "${tag}" "$(dirname "${file}")" >/dev/null +} + +# toolchain-docker:bases prints "<name:tag>\t<pinned digest or nothing>" for every FROM line that names an image. +# It skips scratch and earlier stages of the same file (named by `AS <stage>`). +toolchain-docker:bases() { + awk ' + toupper($1) == "FROM" { + i = 2 + if ($i ~ /^--platform=/) i++ + ref = $i + if (toupper($(i + 1)) == "AS") stage[tolower($(i + 2))] = 1 + if (ref == "scratch" || (tolower(ref) in stage)) next + name = ref; digest = "" + if (index(ref, "@") > 0) { name = substr(ref, 1, index(ref, "@") - 1); digest = substr(ref, index(ref, "@") + 1) } + print name "\t" digest + }' "$(toolchain-docker:file)" +} + +# toolchain-docker:latest <name:tag> prints the tag's current digest in the registry. +toolchain-docker:latest() { + docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' 2>/dev/null | jq --raw-output '.digest' +} + +toolchain-docker:outdated() { + local base pinned latest + toolchain-docker:require || return 1 + while IFS=$'\t' read -r base pinned + do + [[ -n "${base}" ]] || continue + latest=$(toolchain-docker:latest "${base}") || { warn "${base}: the registry did not answer"; continue; } + [[ "${latest}" == "${pinned}" ]] || printf '%s\t%s\t%s\n' "${base}" "${pinned:-unpinned}" "${latest}" + done < <(toolchain-docker:bases) +} + +toolchain-docker:update() { + local file base pinned latest quoted from + file=$(toolchain-docker:file) + while IFS=$'\t' read -r base pinned latest + do + [[ -n "${base}" ]] || continue + # An image reference never contains a comma, so each sed command uses a comma as its delimiter. + quoted=$(ere:quote "${base}") + if [[ "${pinned}" == unpinned ]] + then + from='^([[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+(--platform=[^[:space:]]+[[:space:]]+)?)' + sed -i.kit-old -E "s,${from}${quoted}([[:space:]]|\$),\\1${base}@${latest}\\3," "${file}" + else + sed -i.kit-old "s,${quoted}@${pinned},${base}@${latest}," "${file}" + fi + rm -f "${file}.kit-old" + done < <(toolchain-docker:outdated) + toolchain-docker:install +} + +# The tools are in the image, so path prints nothing. A command reaches them through run. +toolchain-docker:path() { return 0; } + +toolchain-docker:run() { + local tag + toolchain-docker:require || return 1 + tag=$(toolchain-docker:tag) || return 1 + toolchain-docker:install || return 1 + docker run --rm --volume "${PWD}:/work" --workdir /work --user "$(id -u):$(id -g)" --env HOME=/tmp \ + "${tag}" "$@" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/toolchain-mise.sh b/bin/toolchain-mise.sh new file mode 100644 index 0000000..51a26c3 --- /dev/null +++ b/bin/toolchain-mise.sh @@ -0,0 +1,47 @@ +# shellcheck shell=bash +# toolchain-mise.sh - the mise toolchain provider: mise.toml pins the tools, mise.lock records them (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/toolchain.sh loads this provider for `provider: mise`, or when mise.toml or .mise.toml is at the repository +# root. The provider needs mise. When the caller sets MISE_ENV, mise layers mise.<env>.toml on top, as it always +# does. + +toolchain-mise:file() { + if [[ -f .mise.toml && ! -f mise.toml ]] + then printf '.mise.toml\n' + else printf 'mise.toml\n' + fi +} + +toolchain-mise:require() { + command -v mise >/dev/null 2>&1 && return 0 + error "install mise: https://mise.jdx.dev/installing-mise.html (the mise toolchain needs it)" + return 1 +} + +toolchain-mise:install() { + toolchain-mise:require || return 1 + mise trust --quiet "$(toolchain-mise:file)" >/dev/null 2>&1 || true + mise install --quiet +} + +# toolchain-mise:outdated reads mise outdated --json: an object keyed by tool name, with each tool's current and +# latest version. +toolchain-mise:outdated() { + local doc + toolchain-mise:require || return 1 + doc=$(mise outdated --json 2>/dev/null) || { error "mise outdated could not run; see: mise doctor"; return 1; } + jq --raw-output 'to_entries[] | "\(.key)\t\(.value.current // "none")\t\(.value.latest)"' <<<"${doc:-{\}}" +} + +toolchain-mise:update() { + toolchain-mise:require || return 1 + mise upgrade --quiet +} + +toolchain-mise:path() { + toolchain-mise:require || return 1 + mise bin-paths 2>/dev/null +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/toolchain.sh b/bin/toolchain.sh new file mode 100644 index 0000000..d88b8f5 --- /dev/null +++ b/bin/toolchain.sh @@ -0,0 +1,136 @@ +# shellcheck shell=bash +# toolchain.sh - one interface to the tool managers (mise, brew, docker) that install the gate's tools (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Each repository picks the tool managers that install the tools its gate runs, on the root checkout (the desk) +# and on a CI runner. Each tool manager is a provider: toolchain-<name>.sh in this directory, whose functions are +# named toolchain-<name>:<call>. A provider reads the fields of its settings record with toolchain:field. +# +# A repository can use more than one provider. They are chosen by: +# +# 1. the `toolchains` list in .kit/settings.yml, in the order listed +# 2. otherwise every provider whose file is at the repository root, in this order: +# - mise: mise.toml or .mise.toml +# - brew: Brewfile +# - docker: toolchain.Dockerfile +# +# A `toolchains` setting looks like this: +# +# toolchains: +# - provider: mise +# - provider: brew +# file: Brewfile +# - provider: docker +# file: toolchain.Dockerfile +# image: example/toolchain +# +# Every provider implements the calls below, except where noted. +# +# file the file it reads, relative to the repository root +# require succeeds when its own tool is present; otherwise fails with a remedy +# install installs every pinned tool, from the file and its lock +# outdated one line per tool that is behind its source: "<name>\t<installed>\t<latest>" +# update moves every tool to its latest version, and rewrites the pins and the lock +# path the directories to put first on PATH for its tools, one per line +# run <command>… runs the command with its tools; only docker implements it, because it isolates +# +# toolchain:run runs a command inside the container when docker is declared, and otherwise with every +# toolchain's path first on PATH. Either way the gate sees the same tools on the desk and on a runner. +# +# see: docs/toolchains.md + +use release yaml + +# toolchain:providers prints the providers this release carries, space-separated. +toolchain:providers() { + # shellcheck disable=SC2154 # kit_bin_dir is declared by bin/1st.sh + (cd "${kit_bin_dir}" && find . -maxdepth 1 -name 'toolchain-*.sh' | sed 's@^\./toolchain-@@; s@\.sh$@@' \ + | LC_ALL=C sort | paste -s -d ' ' -) +} + +# toolchain:declared prints the chosen providers, one per line, as the file header describes. It fails, naming +# the provider, when a declared provider is not one the kit has. +toolchain:declared() { + local provider found='' + local -a declared=() + while IFS=$'\x1f' read -r provider + do + [[ -n "${provider}" ]] && declared+=("${provider}") + done < <(yaml:records "$(release:settings)" toolchains provider) + if (( ${#declared[@]} > 0 )) + then + for provider in "${declared[@]}" + do + if [[ ! -f "${kit_bin_dir}/toolchain-${provider}.sh" ]] + then + error "'${provider}' is not a toolchain the kit knows; the toolchains are: $(toolchain:providers)" \ + "(toolchains in .kit/settings.yml)" + return 1 + fi + done + printf '%s\n' "${declared[@]}" + return 0 + fi + [[ -f mise.toml || -f .mise.toml ]] && found+=$'mise\n' + [[ -f Brewfile ]] && found+=$'brew\n' + [[ -f toolchain.Dockerfile ]] && found+=$'docker\n' + printf '%s' "${found}" +} + +# toolchain:field <provider> <field> prints a field of the provider's record, or nothing. The while loop reads +# every record, even after a match, so yaml:records never writes into a pipe that nobody reads. +toolchain:field() { + local provider value found='' + while IFS=$'\x1f' read -r provider value + do + [[ -z "${found}" && "${provider}" == "$1" ]] && found=${value} + done < <(yaml:records "$(release:settings)" toolchains provider "$2") + [[ -z "${found}" ]] || printf '%s\n' "${found}" +} + +# toolchain:call <provider> <call> <argument>… runs the provider's function for the call. +toolchain:call() { + local provider=$1 call=$2 + shift 2 + use "toolchain-${provider}" || return 1 + "toolchain-${provider}:${call}" "$@" +} + +# toolchain:each <call> runs the call on every declared toolchain in order, stopping at the first that fails. +toolchain:each() { + local provider providers + providers=$(toolchain:declared) || return 1 + for provider in ${providers} + do + toolchain:call "${provider}" "$@" || return 1 + done +} + +# toolchain:path prints the directories every declared toolchain puts first on PATH, in order, one per line. +toolchain:path() { + toolchain:each path +} + +# toolchain:isolating prints docker when docker is declared, otherwise nothing. docker is the only provider that +# runs a command in its own container. +toolchain:isolating() { + local providers + providers=$(toolchain:declared) || return 1 + grep --line-regexp docker <<<"${providers}" || true +} + +# toolchain:run <command>… runs the command with the declared toolchains' tools: inside the container when docker +# is declared, otherwise with every toolchain's path first on PATH. With no toolchain, the command runs as is. +toolchain:run() { + local isolating dirs + isolating=$(toolchain:isolating) || return 1 + if [[ -n "${isolating}" ]] + then + toolchain:call "${isolating}" run "$@" + return + fi + dirs=$(toolchain:path | paste -s -d ':' -) || return 1 + PATH="${dirs:+${dirs}:}${PATH}" "$@" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/tracker-ado.sh b/bin/tracker-ado.sh new file mode 100644 index 0000000..a8d06eb --- /dev/null +++ b/bin/tracker-ado.sh @@ -0,0 +1,92 @@ +# shellcheck shell=bash +# tracker-ado.sh - the Azure DevOps tracker provider: work items, through az boards (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/tracker.sh loads this provider for a prefix whose record sets `provider: ado`. The record gives the +# organization's URL, the project that new work items are created in, and optionally the work item type (default: +# Task): +# +# trackers: +# - prefix: "AB#" +# provider: ado +# organization: https://dev.azure.com/example +# project: Claims +# type: Task +# +# The provider needs the az CLI with its azure-devops extension. No other part of the kit needs az. +# +# A work item is CLOSED in the states Closed, Done, and Removed (the closing states of Azure DevOps' process +# templates) and OPEN in every other state. The caller (me) is the identity az is signed in as, which can differ +# from the login on the forge (GitHub or GitLab). + +# shellcheck disable=SC2154 # TRACKER_PREFIX is set by tracker:of and tracker:at for the call +tracker-ado:organization() { + local org + org=$(tracker:field "${TRACKER_PREFIX}" organization) + if [[ -z "${org}" ]] + then + error "add organization: <https://dev.azure.com/name> to the ${TRACKER_PREFIX} tracker in .kit/settings.yml" + return 1 + fi + printf '%s\n' "${org%/}" +} + +# tracker-ado:show <id> prints the work item as az shows it. +tracker-ado:show() { + local org + org=$(tracker-ado:organization) || return 1 + guard:require tool:az || return 1 + az boards work-item show --id "$1" --org "${org}" --output json 2>/dev/null \ + || { error "run: az login (work item ${TRACKER_PREFIX}$1 could not be read from ${org})"; return 1; } +} + +# tracker-ado:read <id> <filter> applies a jq filter to the work item. It fails when az cannot show the item. +tracker-ado:read() { + local item + item=$(tracker-ado:show "$1") || return 1 + jq --raw-output "$2" <<<"${item}" +} + +tracker-ado:title() { tracker-ado:read "$1" '.fields["System.Title"]'; } + +tracker-ado:state() { + tracker-ado:read "$1" 'if (.fields["System.State"] | IN("Closed", "Done", "Removed")) then "CLOSED" else "OPEN" end' +} + +tracker-ado:assignees() { tracker-ado:read "$1" '.fields["System.AssignedTo"].uniqueName // ""'; } + +tracker-ado:assign() { + local org + org=$(tracker-ado:organization) || return 1 + az boards work-item update --id "$1" --assigned-to "$2" --org "${org}" --output none 2>/dev/null +} + +tracker-ado:me() { + guard:require tool:az || return 1 + az account show --query user.name --output tsv 2>/dev/null \ + || { error "run: az login (the Azure CLI is not signed in)"; return 1; } +} + +tracker-ado:create() { + local org project type item + org=$(tracker-ado:organization) || return 1 + project=$(tracker:field "${TRACKER_PREFIX}" project) + type=$(tracker:field "${TRACKER_PREFIX}" type) + if [[ -z "${project}" ]] + then + error "add project: <name> to the ${TRACKER_PREFIX} tracker in .kit/settings.yml" + return 1 + fi + item=$(az boards work-item create --title "$1" --type "${type:-Task}" --project "${project}" --org "${org}" \ + --output json 2>/dev/null) || { error "run: az login (the work item could not be created)"; return 1; } + jq --raw-output '.id' <<<"${item}" +} + +tracker-ado:url() { + local org project + org=$(tracker-ado:organization) || return 1 + project=$(tracker:field "${TRACKER_PREFIX}" project) + printf '%s/%s/_workitems/edit/%s\n' "${org}" "${project// /%20}" "$1" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/tracker-forge.sh b/bin/tracker-forge.sh new file mode 100644 index 0000000..0635972 --- /dev/null +++ b/bin/tracker-forge.sh @@ -0,0 +1,27 @@ +# shellcheck shell=bash +# tracker-forge.sh - the tracker provider for the forge's own issues, on GitHub or GitLab (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# bin/tracker.sh loads this provider for the prefix `#`, unless a record in `trackers` declares `#`. Each call +# except url passes straight to the matching issue call of the forge interface, bin/forge.sh, so the forge's +# provider does the work. url builds the issue's address from the repository's URL. + +use forge + +tracker-forge:title() { forge:issue-title "$1"; } +tracker-forge:state() { forge:issue-state "$1"; } +tracker-forge:assignees() { forge:issue-assignees "$1"; } +tracker-forge:assign() { forge:issue-assign "$1" "$2"; } +tracker-forge:me() { forge:me; } +tracker-forge:create() { forge:issue-create "$1"; } + +tracker-forge:url() { + local repo_url + repo_url=$(forge:repo-url) || return 1 + if [[ "$(forge:provider)" == gitlab ]] + then printf '%s/-/issues/%s\n' "${repo_url}" "$1" + else printf '%s/issues/%s\n' "${repo_url}" "$1" + fi +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/tracker.sh b/bin/tracker.sh new file mode 100644 index 0000000..ce00d3f --- /dev/null +++ b/bin/tracker.sh @@ -0,0 +1,196 @@ +# shellcheck shell=bash +# tracker.sh - one interface to the issue trackers: forge issues, Azure DevOps work items (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Verbs that read, assign, or create issues call tracker:of or tracker:at here. Each call is passed to the +# provider for the issue's tracker: tracker-<name>.sh in this directory, whose functions are named +# tracker-<name>:<call>. +# +# The tracker is not always the forge, the service that hosts the repository (GitHub or GitLab). GitHub and GitLab +# issues are on the forge; Azure DevOps work items and Jira issues are not. A repository can use more than one +# tracker at once. +# +# An issue reference names its tracker with a prefix, written the way that tracker writes it: +# +# - `#7` or `7`: an issue in the forge's own tracker +# - `AB#4521`: an Azure DevOps work item +# +# The provider for a reference is chosen by: +# +# 1. a bare number is read as `#<number>` +# 2. the longest prefix that the reference starts with, followed only by digits, picks a record: one in the +# `trackers` list in .kit/settings.yml, or the built-in `#` record for the forge's own tracker +# 3. that record's `provider` field names the provider +# +# A record in `trackers` declares a prefix, a provider, and the provider's own fields. A record with the prefix +# `#` replaces the built-in one. +# +# trackers: +# - prefix: "AB#" +# provider: ado +# organization: https://dev.azure.com/example +# project: Claims +# +# Every provider implements the calls below. Each prints its result on stdout. On failure it prints a remedy. A +# call about one issue gets the reference's number, without the prefix, as <id>. TRACKER_PREFIX is set to the +# prefix, and the provider reads its record's fields with tracker:field. +# +# title <id> the issue's title +# state <id> OPEN or CLOSED +# assignees <id> the assignees' logins, space-separated +# assign <id> <login> adds the assignee +# me the caller's login in this tracker, which can differ from the forge login +# create <title> the number of the issue it creates +# url <id> the issue's browser URL +# +# see: docs/trackers.md + +use release yaml + +# tracker:records prints "<prefix>\x1f<provider>" for every declared tracker, then "#\x1fforge" for the forge's +# own tracker, unless a record declares the prefix `#`. +tracker:records() { + local prefix provider forge_claimed=false + while IFS=$'\x1f' read -r prefix provider + do + [[ -n "${prefix}" ]] || continue + [[ "${prefix}" == '#' ]] && forge_claimed=true + printf '%s\x1f%s\n' "${prefix}" "${provider}" + done < <(yaml:records "$(release:settings)" trackers prefix provider) + [[ "${forge_claimed}" == true ]] || printf '#\x1fforge\n' +} + +# tracker:field <prefix> <field> prints a field of the prefix's record, or nothing. Every while loop over records +# here reads every record, even after a match, so the command feeding it never writes into a pipe that nobody +# reads. +tracker:field() { + local prefix value found='' + while IFS=$'\x1f' read -r prefix value + do + [[ -z "${found}" && "${prefix}" == "$1" ]] && found=${value} + done < <(yaml:records "$(release:settings)" trackers prefix "$2") + [[ -z "${found}" ]] || printf '%s\n' "${found}" +} + +# tracker:parse <reference> prints "<prefix>\t<number>": the longest declared prefix that the reference starts +# with, and the digits after it. A bare number is in the forge's own tracker. When no tracker matches the +# reference, it fails and lists the prefixes. +tracker:parse() { + local ref=$1 prefix best='' provider + [[ "${ref}" =~ ^[0-9]+$ ]] && ref="#${ref}" + while IFS=$'\x1f' read -r prefix provider + do + if [[ "${ref}" == "${prefix}"* && "${ref#"${prefix}"}" =~ ^[0-9]+$ && ${#prefix} -gt ${#best} ]] + then + best=${prefix} + fi + done < <(tracker:records) + if [[ -z "${best}" ]] + then + error "'$1' is not an issue reference here; the trackers answer $(tracker:records | cut -d $'\x1f' -f 1 \ + | sed 's/$/<number>/' | paste -s -d ' ' -) (trackers in .kit/settings.yml)" + return 2 + fi + printf '%s\t%s\n' "${best}" "${ref#"${best}"}" +} + +# tracker:provider <prefix> prints the provider for the prefix. +tracker:provider() { + local prefix provider found='' + while IFS=$'\x1f' read -r prefix provider + do + [[ -z "${found}" && "${prefix}" == "$1" ]] && found=${provider} + done < <(tracker:records) + if [[ -z "${found}" ]] + then + error "no tracker answers '$1'" + return 1 + fi + printf '%s\n' "${found}" +} + +# tracker:of <reference> <call> <argument>… runs the call on the reference's tracker, passing the reference's +# number first. +tracker:of() { + local ref=$1 call=$2 prefix number provider + shift 2 + IFS=$'\t' read -r prefix number < <(tracker:parse "${ref}") || return 2 + [[ -n "${prefix}" ]] || return 2 + provider=$(tracker:provider "${prefix}") || return 1 + use "tracker-${provider}" || return 1 + TRACKER_PREFIX=${prefix} "tracker-${provider}:${call}" "${number}" "$@" +} + +# tracker:at <prefix> <call> <argument>… runs a call that names no issue, such as me or create, on the prefix's +# tracker. +tracker:at() { + local prefix=$1 call=$2 provider + shift 2 + provider=$(tracker:provider "${prefix}") || return 1 + use "tracker-${provider}" || return 1 + TRACKER_PREFIX=${prefix} "tracker-${provider}:${call}" "$@" +} + +# tracker:normal <reference> prints the reference in its tracker's own form: `7` becomes `#7`. +tracker:normal() { + local prefix number + IFS=$'\t' read -r prefix number < <(tracker:parse "$1") || return 2 + [[ -n "${prefix}" ]] || return 2 + printf '%s%s\n' "${prefix}" "${number}" +} + +# tracker:is-forge <reference> succeeds when the reference is in the forge's own tracker. The forge can then link +# a branch to the issue and close the issue on merge by itself. +tracker:is-forge() { + local prefix number + IFS=$'\t' read -r prefix number < <(tracker:parse "$1" 2>/dev/null) || return 1 + [[ -n "${prefix}" && "$(tracker:provider "${prefix}")" == forge ]] +} + +# tracker:key <reference> prints the reference in the form a branch name starts with: forge issue 7 as `7`, work +# item AB#4521 as `ab4521` (lowercased, with everything but letters and digits removed). +tracker:key() { + local ref + ref=$(tracker:normal "$1") || return 2 + if tracker:is-forge "${ref}" + then + printf '%s\n' "${ref#\#}" + else + tr '[:upper:]' '[:lower:]' <<<"${ref}" | tr -cd 'a-z0-9' + printf '\n' + fi +} + +# tracker:branch-ref <branch> prints the reference a branch was made for, read back from the key the branch name +# starts with, or nothing. +tracker:branch-ref() { + local branch=$1 prefix provider key found='' + while IFS=$'\x1f' read -r prefix provider + do + [[ -z "${found}" ]] || continue + if [[ "${provider}" == forge ]] + then + [[ "${branch}" =~ ^([0-9]+)- ]] && found="${prefix}${BASH_REMATCH[1]}" + else + key=$(tr '[:upper:]' '[:lower:]' <<<"${prefix}" | tr -cd 'a-z0-9') + [[ "${branch}" =~ ^${key}([0-9]+)- ]] && found="${prefix}${BASH_REMATCH[1]}" + fi + done < <(tracker:records) + [[ -z "${found}" ]] || printf '%s\n' "${found}" +} + +# tracker:body-refs <body> prints every reference that follows a closing keyword in a change's body, for the +# trackers the forge does not link by itself: `Closes AB#4521`, `fixes AB#4521`, `Resolves: AB#4521`. +tracker:body-refs() { + local body=$1 prefix provider escaped + while IFS=$'\x1f' read -r prefix provider + do + [[ "${provider}" == forge ]] && continue + escaped=$(ere:quote "${prefix}") + grep --only-matching --ignore-case --extended-regexp \ + "(close[sd]?|fix(e[sd])?|resolve[sd]?):? ${escaped}[0-9]+" <<<"${body}" \ + | grep --only-matching --extended-regexp "[0-9]+\$" | sed "s/^/${prefix//\//\\/}/" || true + done < <(tracker:records) +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/bin/yaml.sh b/bin/yaml.sh new file mode 100644 index 0000000..5f04708 --- /dev/null +++ b/bin/yaml.sh @@ -0,0 +1,103 @@ +# shellcheck shell=bash +# yaml.sh - the subset of YAML the kit's own files are written in (sourced module) +# ---------------------------------------------------------------------------------------------------------------------- +# +# The kit reads three kinds of file with this module: +# +# - a module's module.yml +# - the repository's .kit/settings.yml +# - a rule file's frontmatter, between the opening and closing `---` +# +# All three use the same small subset of YAML. One reader handles all of them, so their formats cannot drift +# apart: +# +# key: value a scalar, quoted or not +# key: a list, one ` - item` per line +# - item +# key: a map, one ` name: value` per line +# name: value +# key: a list of records, each opened by ` - field: value` and continued by ` field: value` +# - field: value +# field: value +# +# A value may be double-quoted or single-quoted. A single-quoted value is read literally, backslashes included, +# which is how a pattern is written. +# +# A line starting with # is a comment. Nothing nested more deeply is read; the kit writes no file that needs more. +# When the file starts with `---`, only the frontmatter up to the next `---` is read. + +# yaml:value <file> <key> prints the scalar under <key>, unquoted, or nothing. +yaml:value() { + yaml:read "$1" | awk -v key="$2" ' + $0 ~ "^" key ": " { + v = $0; sub("^" key ": *", "", v) + if (v ~ /^".*"$/ || v ~ /^\x27.*\x27$/) v = substr(v, 2, length(v) - 2) + print v; exit + }' +} + +# yaml:list <file> <key> prints the items of the list under <key>, unquoted, one per line. +yaml:list() { + yaml:read "$1" | awk -v key="$2" ' + $0 == key ":" { on = 1; next } + /^[A-Za-z0-9_-]/ { on = 0 } + on && /^ - / { v = $0; sub(/^ - /, "", v); gsub(/^"|"$/, "", v); print v }' +} + +# yaml:map <file> <key> prints "<name>\t<value>" for every entry of the map under <key>, values unquoted. +yaml:map() { + yaml:read "$1" | awk -v key="$2" ' + $0 == key ":" { on = 1; next } + /^[A-Za-z0-9_-]/ { on = 0 } + on && /^ [A-Za-z0-9_-]+: / { + n = $0; sub(/^ /, "", n); sub(/: .*$/, "", n); v = $0; sub(/^ [A-Za-z0-9_-]+: */, "", v); + if (v ~ /^".*"$/ || v ~ /^\x27.*\x27$/) v = substr(v, 2, length(v) - 2); print n "\t" v }' +} + +# yaml:records <file> <key> <field>… prints one line per record of the list under <key>: the named fields' values, +# in the order named, separated by the unit separator \x1f (not a tab, because a pattern may contain a tab). A +# field the record does not have is empty. +yaml:records() { + local file=$1 key=$2 + shift 2 + yaml:read "${file}" | awk -v key="${key}" -v fields="$*" ' + function unquote(v) { + if (v ~ /^".*"$/ || v ~ /^\x27.*\x27$/) v = substr(v, 2, length(v) - 2) + return v + } + function flush( i, line) { + if (!open) return + line = "" + for (i = 1; i <= n; i++) line = line (i > 1 ? "\x1f" : "") value[names[i]] + print line + delete value + open = 0 + } + BEGIN { n = split(fields, names, " ") } + $0 == key ":" { on = 1; next } + /^[A-Za-z0-9_-]/ { flush(); on = 0 } + on && /^ - [A-Za-z0-9_-]+: / { flush(); open = 1; sub(/^ - /, "") } + on && open && /^ [A-Za-z0-9_-]+: / { sub(/^ /, "") } + on && open && /^[A-Za-z0-9_-]+: / { + f = $0; sub(/: .*$/, "", f); v = $0; sub(/^[A-Za-z0-9_-]+: */, "", v); value[f] = unquote(v) + } + END { flush() }' +} + +# yaml:keys <file> prints the top-level keys, one per line. +yaml:keys() { + yaml:read "$1" | awk '/^[A-Za-z0-9_-]+:/ { k = $0; sub(/:.*$/, "", k); print k }' +} + +# yaml:read <file> prints the file's YAML, without comment lines: the frontmatter when the file starts with ---, +# otherwise the whole file. +yaml:read() { + [[ -f "$1" ]] || return 0 + awk ' + NR == 1 && $0 == "---" { front = 1; next } + front && $0 == "---" { exit } + /^#/ { next } + { print }' "$1" +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..43be6f1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,184 @@ +# Architecture + +kit has three parts: a release that a repository installs, commands that agents and people both run, and checks that +verify the work follows the rules. This page covers: + +- what a release contains and how it reaches a repository, +- how kit finds the rules for a file, +- how a change moves from an issue to `main`, and +- which check verifies which claim. + +[modules.md](modules.md) and [context.md](context.md) describe the module format and the rule lookup in detail. + +## The release and its delivery + +![Delivery: a release's modules, through the verbs, into a repository and a home](delivery.svg) + +A release is one tarball, `kit-<version>.tar.gz`, with the same layout as this checkout: + +- `bin/kit` is the command. Each verb is an executable `bin/kit-<verb>.sh` beside it. The verbs share bash modules, + loaded with `use`. +- `modules/` holds everything a release delivers. +- `version` holds the release version. The release workflow writes it from the tag. A checkout has no `version` file + and reports `unreleased`. + +### Modules + +A module is a directory under `modules/`, named after the module. It contains: + +| Path | Purpose | +| --- | --- | +| `module.yml` | whether the module is on by default, which modules it depends on, and default answers for its templates | +| `files/<base>/` | files copied as they are to the same path under the base | +| `templates/<base>/` | files copied after each `@key@` is replaced by a setting | +| `regions/<base>/` | content kit owns inside a file the repository owns, rendered like a template | +| `docs/` | documentation, packed with the release and never installed | + +There are two bases: `repo` is the repository root, and `home` is the user's home directory. + +### The verbs that move content + +Content reaches a repository only through these verbs: + +- `kit install` resolves the active modules (`kit`, the ones named, and their dependencies), renders them, places the + files, and writes the manifests. +- `kit update` moves to a newly pinned release. It replaces each file the repository never edited and merges each + edited file three ways, with the previous release as the base. +- `kit check` fails when a delivered file differs from the release. +- `kit module` changes the set of active modules. + +How a file is placed depends on its destination: + +- A `.json` file, other than a schema, is merged by key. kit owns only the entries it writes. +- A region lives inside a file the repository owns. It starts at a `kit:generate` line naming the file's path and + ends at a matching end line. `kit generate` replaces the region with the output of `kit region`. It does the same + for every region any file in the repository declares; see [generate.md](generate.md). +- Every other file is copied. + +### Manifests + +Each base has a manifest: + +- `.kit/manifest`, committed with the repository. +- `~/.kit/manifest` on a developer machine, when a module delivers to the home directory. + +A manifest records the version, the active modules, and the release's digest of every delivered path. `kit update` +compares a file against its digest to tell whether the repository edited it. + +## Finding a file's rules + +![Context: how any agent, and Claude Code's hook, find the rules that govern a file](context.svg) + +Each rule lives in one file, `.kit/rules/<kind>.md`, and is not copied into any agent tool's own directory. Its +frontmatter says which files it covers: + +- `paths:` lists globs matched against the repository-relative path. +- `content:` lists patterns matched against the first line of an existing file. + +`kit context <path>` prints the rules that cover a path and appends a line to `.git/kit/context.log`. Every caller +gets the same answer: + +- an agent in any harness, following the instruction in `.kit/1st.md`, +- a person at the shell, +- a tool reading `--json`, and +- Claude Code's `PreToolUse` hook, which the `claude` module wires to `kit context --claude`, so the rules are shown + before every write. + +The log records which rules were shown, and when. + +A rule also names its linter. `kit lint` runs each rule's linter over the files the rule covers, so a hook manager +only has to call `kit lint`. A rule's templates are skeleton files of its kind: `kit new file <path>` creates a file +from one, and `kit lint` checks that every file the rule covers keeps the shape of one; see +[templates.md](templates.md). + +## The workflow and the gate + +![The workflow: an issue becomes a worktree and a draft pull request, the gate checks it, a human merges](loop.svg) + +The desk is the root checkout on the human's machine, on whatever branch it currently has, and it belongs to the +human. It usually has `main` checked out, and it may have any branch that no worktree is using. No agent changes it +in place. + +Work starts from an issue: + +1. `kit issue <N>` creates `worktrees/<N>-<slug>`, a branch linked to the issue, a start commit, and a draft pull + request. If the desk has uncommitted work, `kit issue` moves it into the new worktree and says so. That is the + only change it makes to the desk. +2. In the worktree: + - `kit context` before changing a file, + - `kit lint` to run the pre-commit linters by hand, + - `kit rebase` to rebase the branch onto the default branch at origin and push it. +3. `gh pr ready` once the pull request body holds the report. +4. A human merges, and `kit tidy` removes the worktree and the branch. + +### Forges, interfaces, and providers + +A forge is the service that hosts a repository with its issues and pull requests, such as GitHub or GitLab. kit says +forge rather than GitHub because the workflow runs on more than one. + +Four files each define an interface: a list of calls, and the rule that picks which provider implements them. Shell +has no types, so the choice is made at run time: a setting, or origin's host, becomes part of a function name. In +Michael Feathers' terms (*Working Effectively with Legacy Code*), each is a seam. + +| Interface | Providers | Chosen by | +| --- | --- | --- | +| `bin/forge.sh` | `forge-github.sh` over `gh`, `forge-gitlab.sh` over `glab` | the `forge` setting, else origin's host | +| `bin/tracker.sh` | `tracker-forge.sh`, `tracker-ado.sh` | the reference's prefix, `#7` or `AB#4521`; see [trackers.md](trackers.md) | +| `bin/toolchain.sh` | `toolchain-mise.sh`, `toolchain-brew.sh`, `toolchain-docker.sh` | the `toolchains` setting, else the files present; see [toolchains.md](toolchains.md) | +| `bin/policy.sh` | `policy-patterns.sh`, `policy-opa.sh` | `policy_engine: opa` or a `.rego` file under `.kit/policy/`, else patterns; see [policy.md](policy.md) | + +Every forge call the workflow makes goes through `forge:` in `bin/forge.sh`: an issue's title, the branch linked to +it, a pull request and its state, checks, review threads, and linked issues. A self-hosted forge must be named in the +`forge` setting. + +- Every provider returns the same shape, so `kit status` shows the same picture on either forge. +- Each provider uses its forge's own name for a change: pull request `#3` on GitHub, merge request `!3` on GitLab. +- A repository with one origin has one forge. A repository on two forges is a mirror or a migration, and the setting + picks the one the workflow uses. +- `tests/forge-conformance.bats` runs every provider against a fake of its tool and checks that each returns the + same shape. +- The core verbs never call the forge, so a repository with no forge can still install, update, check, and run + `kit context`. Only a provider needs its forge's tool. + +### The checks + +`kit ci` is the delivered check. The delivered GitHub Actions job runs it on every pull request into the default +branch, and any other pipeline can run it too. It runs four checks, each a kit command: + +- `kit gate` runs the repository's own gate: the `gate` setting, or a detected justfile recipe, Makefile target, or + `package.json` script. It fails when there is no gate, because a gate that cannot run verifies nothing. The gate + runs with the tools the repository's toolchains manage (mise, Homebrew, or a container image); see + [toolchains.md](toolchains.md). +- `kit check` fails when a delivered file differs from the release. +- `kit linked` fails when a pull request is not linked to an open issue. +- `kit policy` fails when a branch name, commit subject, or pull request title breaks the repository's policy. The + policy is a set of patterns or an Open Policy Agent policy; see [policy.md](policy.md). + +`kit status` shows everything in flight: + +- what the last fetch brought in, +- the desk and every worktree, with its uncommitted paths and its commits since the branch point, and +- every open pull request, with its state, age, and checks. + +It queries the forge in the background and draws the local checkouts while it waits. + +## What verifies what + +| Claim | Verified by | +| --- | --- | +| A delivered file matches the release | `kit check`, in the gate and locally, against the release's files and the manifest's version | +| A change has an issue | `kit linked` in the gate; `kit issue` creates the link when it starts the work | +| The repository's own gate ran | `kit gate`, which fails when there is no gate to run | +| Branch names, commit subjects, and titles follow the policy | `kit policy`, with patterns or Open Policy Agent | +| The gate ran with the pinned tools | `kit toolchain install`, then the gate run through the toolchain, locally and in CI | +| All four held, wherever CI runs | `kit ci`, which runs all four and reports each one | +| An agent was shown a file's rules | the Claude Code hook; `kit context` in other harnesses | +| An agent asked for a file's rules | `.git/kit/context.log`, one line per request | +| A file follows its rule's mechanical checks | `kit lint`, each rule's linter over the files it covers | +| A file keeps its rule's shape | `kit lint`, against the rule's templates; `kit new file` creates it in that shape | +| The forbidden git commands were not used | the harness's deny list, delivered by its adapter module | +| The desk was not changed | `kit rebase` refusing the root checkout; `kit issue` never switching its branch | + +No check verifies that an agent followed a rule after reading it. The linters cover the mechanical part for the kinds +of file they handle. The rest is the report in the pull request body, which the integrity rules in `.kit/1st.md` +govern. diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..43adc26 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,81 @@ +# Context + +`kit context` prints the rule files that govern a path. Run it before you create or change a file, then +read each rule it names. + +```sh +kit context "${path}" +``` + +- The output is the rule files to read, one per line. Given several paths, it prints each rule once. +- `--json` prints one object per path, with that path's rules. +- Every call appends one line to `.git/kit/context.log`: the epoch time and the path. + +Each rule is written once, in `.kit/rules/<kind>.md`, and copied nowhere else. + +## How a rule declares the files it governs + +A rule file opens with a frontmatter block. Every key in it is optional: + +```yaml +--- +paths: + - "**/*.sh" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" +lint: shellcheck --shell=bash --external-sources +templates: + - variant: script + executable: true + - variant: module +--- +``` + +| Key | Contents | +| --- | --- | +| `paths:` | Globs matched against the repository-relative path. | +| `content:` | Extended regular expressions matched against the first line of a file that already exists. | +| `lint:` | The linter command for the files the rule governs. | +| `templates:` | The shapes a file of this kind takes; see [templates.md](templates.md). | + +A rule governs a file when any of its globs or any of its patterns matches. + +- In a glob, `**` matches any depth, and a lone `*` also crosses directories. So `**/*.sh` and `*.sh` both match + every `.sh` file. +- A glob with no wildcard, such as `justfile`, matches that one path. +- A `content:` pattern lets a bash script with no extension match by its shebang. +- A file that does not exist yet is matched by its path alone. + +`kit lint` runs each rule's `lint:` command once over the paths that rule governs. The paths are the staged paths, +the paths you name, or every tracked path with `--all-files`. A linter that is not on `PATH` is a failure, not a +skip. The kit assumes no hook manager; the `lefthook` module adds a pre-commit hook that runs `kit lint`. + +[templates.md](templates.md) describes templates, `kit new file` (which makes a file from one), and the shape check +`kit lint` runs against them. + +## Harness adapters + +A harness that can run a command before a write can call `kit context` itself, so the agent gets the rules without +having to remember to ask. The `claude` module adds a Claude Code `PreToolUse` hook on `Write`, `Edit`, and +`MultiEdit` that runs: + +```sh +kit context --claude +``` + +This command: + +1. Reads the hook's input on stdin. +2. Takes the file path from the tool's input. +3. Prints the hook's output, naming the rules without quoting them, so the agent still reads each rule itself. + +```json +{"hookSpecificOutput": {"hookEventName": "PreToolUse", + "additionalContext": "kit: lib/run.sh is governed by .kit/rules/bash.md; read it before this change."}} +``` + +It prints nothing when the tool input has no file path or no rule governs the file. Nothing is kept +between calls except the log line. + +A harness with no hook, and a person, run `kit context "${path}"` directly. `.kit/1st.md` lists it as the step +before creating or changing any file. diff --git a/docs/context.svg b/docs/context.svg new file mode 100644 index 0000000..77aa820 --- /dev/null +++ b/docs/context.svg @@ -0,0 +1,85 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1040 440" width="1040" height="440" role="img" + aria-labelledby="title desc"> + <title id="title">Context: how any agent, and Claude Code's hook, find the rules that govern a file + An agent or a person runs kit context with a path. The kit matches the path and the file's first + line against each rule's frontmatter, names the governing rules, and appends the ask to the context log. Claude + Code's PreToolUse hook makes the same call before every write. + + + + + + + + + + Any agent, any harness + Before creating or changing a file, + as .kit/1st.md says: + kit context <path>… + + + A person + The same call, by hand or in a + script, or with --json for a tool. + kit context --json <path>… + + + Claude Code + PreToolUse on Write, Edit, MultiEdit, + wired by the claude module: + kit context --claude + + + + kit context + For each path, every rule file whose + frontmatter matches: + paths: + globs on the repository path + content: + patterns on the first line + Then the ask is appended to the log. + Nothing is copied; the rule is named, + and reading it stays the caller's act. + + + + The rules, once + .kit/rules/<kind>.md + --- + paths: ["**/*.sh"] + content: ["^#!.*/(env +)?(ba)?sh( |$)"] + --- + + + + The record + .git/kit/context.log + epoch and path per ask: the record of what was named + + + + The answer + rule paths, one per line; as JSON; or as the + hook's additionalContext naming them + + + + + + + reads + + appends + + prints + diff --git a/docs/delivery.svg b/docs/delivery.svg new file mode 100644 index 0000000..a8e5621 --- /dev/null +++ b/docs/delivery.svg @@ -0,0 +1,101 @@ + + Delivery: a release's modules, through the kit's verbs, into a repository and a home + The release carries bin/ and modules/. The verbs render templates, merge JSON by key, and copy the + rest into the repository root and the user's home, each recorded in its own manifest. + + + + + + + + + + Release + kit-<version>/ + + + bin/ + kit + the command + kit-<verb>.sh + one per verb + release.sh context.sh … + sourced modules + + + modules/<name>/ + module.yml + description, default, + depends, answers + files/repo/ + mirrored at the root + files/home/ + mirrored at ~ + templates/<base>/ + @key@ rendered; regions/<base>/ + docs/ + packed, placed nowhere + kit is default; ci-github, lefthook, + and claude are named; each may depend + on others, resolved at install. + version + stamped by the release workflow + + + + The verbs + kit install [<module>…] + kit update + kit check + kit module list|add|remove + + resolve the active set and its depends + render @key@ from .kit/settings.yml + merge a .json destination by key; generate a region + copy everything else + digest each path into the manifest + three-way merge an edited file on update + + + + Repository root + base: repo + .kit/1st.md + .kit/rules/<kind>.md + .kit/lefthook.yml + .github/workflows/kit-ci.yml + .claude/CLAUDE.md + region + .claude/settings.json + merged + + .kit/manifest + version, modules, digests + .kit/settings.yml + yours, never written + AGENTS.md .gitignore + regions + + + + Home + base: home, the user's ~ + ~/.kit/manifest + the desk's record, not the repository's; + a runner has none, and kit check says so. + No module in this release delivers here. + + + + + + diff --git a/docs/generate.md b/docs/generate.md new file mode 100644 index 0000000..05efefc --- /dev/null +++ b/docs/generate.md @@ -0,0 +1,162 @@ +# Generate + +A region is a part of a file that a command writes and a person does not edit. Mark a region with a directive in +the file's own comment syntax, then run `kit generate` to replace the region's content with its command's output. + +Examples of regions: + +- a table built from the manifests +- a list of commands built from their help text +- the sentence the kit puts in `AGENTS.md` + +A hand edit makes a region stale. `kit check` fails on a stale region, so `kit ci` fails wherever it runs. + +## The directive + +A region opens on a line with the marker `kit:generate`, a name, and optionally a command in backticks. It closes on +a line with `kit:end` and the same name. On either line, only punctuation may come before or after the marker: the +comment opener before, and the comment closer after. + +```text + + +… + +``` + +Both marker lines stay. The command's standard output replaces the lines between them. A markdown page contains: + +```markdown + +Read .kit/1st.md at the start of every session. It defines how any agent uses empowerite/kit to keep this +repository trustworthy. + +``` + +Other files use their own comment syntax: + +- `#` in a shell script or a `.gitignore` +- `//` in a JavaScript file +- `--` in a SQL file +- `'` in a Visual Basic file + +`kit generate` has no list of comment syntaxes. It reads a line as a directive when only non-alphanumeric +characters come before the marker and after the closing backtick. Every comment syntax passes this test. A line of +prose does not, because it has letters before the marker. + +A line that starts with the marker but is not a well-formed directive is an error. It is never skipped, so a +misspelled directive is reported instead of left stale. To show the syntax on a markdown page, put it in a fenced +code block, which `kit generate` does not read, or in prose with a word before the marker. + +### The name + +The name is any run of characters with no whitespace and no backtick, separated from the marker by +whitespace. It pairs the directive with its end marker, and it must be unique in the file. A region the kit +delivers is named by its path. + +### The command + +The command is a bash command line, so pipes, redirections, and `$()` all work. It runs: + +- from the repository root +- with the running kit's `bin/` first on `PATH` +- with standard input closed +- with `errexit` and `pipefail` set +- with `KIT_GENERATE_FILE` set to the file and `KIT_GENERATE_REGION` set to the region's name + +The command is everything between the first backtick after the name and the last backtick before the comment +closer. It cannot contain a literal backtick; use `$()` instead. A command that needs more than one line belongs +in a script in the tree, where it can be read and tested. + +### No command + +A directive with no command runs `kit region`. `kit region` reads the region's name from its environment and +treats it as the path of a region an active module delivers. A name no module delivers fails, and the error names +the directive's line, so a forgotten command never passes silently. The directive can be in any file; the name +only selects the region. + +## Nesting and balance + +Each unit must be balanced on its own. A unit is either the file as written or one command's output. + +- A region opened in a unit must close in that unit. +- An end marker closes the region most recently opened in that unit. +- A command's output may open regions of its own, as long as it closes them in the same output. `kit generate` + expands those too, depth first, so regions can nest and one pass settles a file. +- An output that closes a region it did not open, or opens one it does not close, is refused. The error names the + file, the directive's line, and the command. + +So a nested region is always one the enclosing command wrote. A hand-written directive inside a region is +replaced along with the rest of the region's content. + +## Commands + +`kit generate […]` expands the regions in files. + +- Named paths are relative to the directory you typed the command in. Each named file gets one pass, or up to + `--iterations=` passes. +- With no paths, it expands every tracked file and every untracked file git does not ignore. It repeats passes + until a pass changes nothing, up to twenty passes or `--iterations=`, because one command may read another + file's region. +- A run over the whole repository that is still changing at the limit fails. The cause is a command whose output + depends on its own output. + +`kit generate --check […]` runs one pass, writes nothing, and fails naming every stale file. It runs +in two places: + +- `kit check`, over the whole repository, so `kit ci` checks every generated region, the kit's and the + repository's own. +- The `lefthook` module's pre-commit hook, over the staged paths. + +`kit region [--home] []` prints the content the active modules deliver as the region at a path, rendered +the way a template is. It is the command behind every region the kit delivers. + +- The path defaults to `KIT_GENERATE_REGION`, so a bare directive needs no argument. +- Only one active module can deliver a region at a given path. `kit region` prints that module's + `regions//`. +- A path no active module delivers is refused. + +## What is refused + +`kit generate` rewrites a file only when the whole expansion succeeds. Each of these leaves the file unchanged and +fails the run, naming the file and the line: + +- a command that fails +- a line that starts with the marker and is not a directive +- an end marker with no open region, or one that names a region other than the open one +- a directive whose region never closes, in the file or in a command's output +- a name opened twice in the expanded file +- in a markdown file or a command's output, a fenced code block that never closes, since the directives after it + could not be read +- a conflict marker in the file (a line of seven `<` or `>`), since a conflicted file cannot be expanded + +## The kit's regions + +A file under a module's `regions//` is a template for a region's content. Every `@key@` in it is +rendered from `.kit/settings.yml` and the module's defaults, as [modules.md](modules.md) describes. + +`kit install` writes the region into the file at ``: the bare directive naming the path, the rendered +content, and the end marker, in the file's comment syntax. Where the block goes depends on the file: + +- The file has the directive and end marker: the content goes between them. +- The file exists without them: the block is appended after a blank line. +- The file does not exist: the block becomes the whole file. + +After that: + +- The repository can move the block anywhere in the file and write anything around it. +- `kit update` regenerates the region. There is no three-way merge, because the region's content is never the + repository's to edit. +- `kit module remove` takes the block out, and removes the file if that leaves it empty. +- A directive with no end marker is refused, by name. +- Two modules that deliver a region to the same file are refused when the kit reads the modules. +- A region under `home` must spell out its command, `kit region --home `, because the bare directive cannot + select the home directory. + +| Module | File | Region | +| --- | --- | --- | +| `kit` | `AGENTS.md` | the sentence that sends every agent to `.kit/1st.md` | +| `kit` | `.gitignore` | the `/worktrees/` entry | +| `claude` | `.claude/CLAUDE.md` | an import of `AGENTS.md` | + +[modules.md](modules.md) describes modules, and [architecture.md](architecture.md) describes the whole kit. diff --git a/docs/loop.svg b/docs/loop.svg new file mode 100644 index 0000000..4a62480 --- /dev/null +++ b/docs/loop.svg @@ -0,0 +1,95 @@ + + The loop: an issue becomes a worktree and a draft pull request, the gate holds it, a human merges + The desk is the root checkout and stays the human's. kit issue makes a worktree per issue with a + linked branch and a draft pull request. Work happens there under kit context, kit lint, and kit rebase. The + delivered CI job runs the gate, kit check, and the linked-open-issue refusal. A human merges, and kit tidy sweeps. + kit status draws the whole picture. + + + + + + + + + + The desk + The root checkout, the human's. Usually on main, + sometimes on a branch no worktree holds. No agent + checks out, resets, or rebases it. + kit status + draws desk, worktrees, pull requests + + + + An issue + A change without one has not started. + A design is prose in its body. + + + + The worktree + kit issue <N> + worktrees/<N>-<slug>, a branch linked under + Development, a start commit, a draft pull request. + + Work happens here, and only here: + kit context <path> + before a change + kit lint + the hook by hand + kit rebase + onto the default branch, pushed + gh pr ready + the body is the report + Signed commits, subject #<N> <area>: <subject>. + + + + The check: kit ci + The delivered job, on a pull request into main: + kit gate + the gate setting, or the one detected + kit check + every delivered file matches the release + kit linked + refused without an open issue of this + repository under Development + On a desk, the pre-commit hook runs the + linters over what is staged. + + + + A human merges + Nothing reaches main any other way, and + nothing is called merged until it is observed. + kit tidy + removes the worktree and its branch + + + + The harness adapter + Deny list: the raw git spellings .kit/1st.md + forbids, rebase, push --force, and the rest. + Hook: kit context before every write. + Nothing copied from the rules. + + + + kit issue + + push + + green, reviewed + + main advances; the desk pulls + diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..1dcc1aa --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,220 @@ +# Modules + +A module is a directory under `modules/` in a release, and everything the kit delivers comes from one. A repository +activates the modules it wants, and `kit install`, `kit update`, and `kit check` work on that active set. + +## Activation + +- `kit install […]` activates `kit`, the one default module, plus the named modules and their + dependencies. It records the set on the `modules` line of `.kit/manifest`. +- `kit module list` shows every module the release has, and which are active. +- `kit module add …` activates more modules, placing only the paths the new set adds to the old one. +- `kit module remove …` deactivates modules. It removes the paths only the removed modules delivered, + and takes the kit's entries back out of any merged `.json` file. + +## Layout + +```text +modules// + module.yml metadata + files// files mirrored at the base's root + templates// files rendered from the repository's answers, then mirrored the same way + regions// each file a region the kit owns inside a file the repository owns + docs/ the module's documentation; packed with the release, placed in no repository +``` + +A module must have `module.yml`. `files/`, `templates/`, `regions/`, and `docs/` are optional. The base +sets where a file lands: + +| Base | Root | Record | +| --- | --- | --- | +| `repo` | the repository root | `.kit/manifest`, committed with the repository | +| `home` | the user's home directory, `~` | `~/.kit/manifest`, on the user's machine; a CI runner has none | + +- `files/home/.kit/` is a user-level setting. Each machine has one copy, from whichever repository installed + or updated last. +- `kit check` compares home paths with the release where `~/.kit/manifest` exists. Where it does not, as on a + runner, which installs nothing there, `kit check` reports that it did not check them. +- A module that names any other base is refused at install, and the error lists the known bases. +- `KIT_HOME` replaces the home directory. The tests use it to stay out of the real one. + +## Metadata + +`module.yml`, a rule's frontmatter, and `.kit/settings.yml` all use the small subset of YAML that `bin/yaml.sh` +reads: scalars, lists of `- item`, and one level of map. A line starting with `#` is a comment. + +```yaml +description: "One GitHub Actions job that runs kit ci" +default: true +depends: + - kit +answers: + ci_runner: ubuntu-latest +``` + +| Key | Meaning | +| --- | --- | +| `description` | Required. One line, shown by `kit module list`. | +| `default` | `true` makes the module active unless removed. In this release only `kit` sets it. | +| `depends` | Modules activated with this one. `kit module remove` refuses a module another active module depends on. | +| `answers` | The default value of each template key. A repository overrides one under `answers:` in `.kit/settings.yml`. | +| `expects` | Lines the module expects in files the repository owns, each a `path`, a `pattern`, and a `remedy`; see below. | + +[module.schema.json](../modules/module.schema.json) is the schema, in JSON Schema 2020-12. This repository's gate +(its own CI command) validates every `module.yml` against it with `ajv`. Without a validator, `kit install` applies +the same contract and refuses, naming the problem: + +- a key it does not know +- a missing `description` +- a `default` that is not `true` or `false` +- a dependency the release does not have + +## Delivery + +Each kind of source file is placed differently: + +| Source | How it is placed | +| --- | --- | +| `files//` | At `` under the base's root: merged by key for a `.json` file, copied otherwise. | +| `templates//` | Rendered, then placed like a `files/` file. | +| `regions//` | Rendered, then written as a region inside the file at ``. | + +### Copies and merges + +- A destination ending in `.json` is merged by key, except a schema (`*.schema.json`). The kit sets its keys and + adds its array entries. On update or removal it takes out only the previous release's entries, so the + repository's own keys and entries stay. +- Every other destination is a copy. +- Where two active modules deliver the same path, the later module in dependency order wins. + +### Includes + +Where a file format supports includes, a module delivers a whole file of its own under `.kit/`. It then expects +the repository's file to include it with one line: + +- lefthook's `extends` +- just's `import` +- git's `include` +- markdownlint's `extends` + +The module declares each such line under `expects:`. An entry has a `path`, a `pattern` (an extended regular +expression that some line of that file must match), and a `remedy`. + +```yaml +expects: + - path: lefthook.yml + pattern: '\.kit/lefthook\.yml' + remedy: "put extends: [.kit/lefthook.yml] in lefthook.yml, so the kit's pre-commit commands run" +``` + +- `kit install` and `kit module add` list each unmet expectation as left to do. +- `kit check` fails until every expectation is met. +- The kit writes nothing into the file. It belongs entirely to the repository. + +A single-quoted value is taken literally, backslashes included. Write patterns that way. + +The kit changes a file it touches in one of four ways: + +| When the file | The kit uses | +| --- | --- | +| has a format that supports includes | an include line the repository adds | +| has a format with comments | a region | +| supports neither | a merge by key | +| belongs entirely to the kit | a copy | + +### Regions + +A file under `regions//` is the content of a region inside the file at the same path. It is rendered first, +like a template. The kit writes three things in the file's comment syntax: the bare directive `kit:generate `, +the content, and the marker `kit:end `. [generate.md](generate.md) describes directives. The comment syntax +follows the file's extension: + +- `` for markdown, HTML, XML, and SVG +- `//` for JavaScript, TypeScript, Go, Rust, C, C++, Java, Kotlin, Swift, C#, and Scala +- `--` for SQL, Lua, and Haskell +- `#` for everything else + +The block is placed like this: + +- The file has the directive: the content between the directive and the end marker becomes what `kit region` + prints. +- The file exists without the directive: the block is appended after a blank line. +- The file does not exist: the block becomes the whole file. + +After that: + +- The repository can move the block anywhere in the file and write anything around it. +- `kit check` checks the region against its command. +- `kit update` regenerates the region and never merges it. +- Removal takes the block out, leaves the rest of the file, and removes the file if that leaves it empty. +- A file holds at most one kit region. Two modules that deliver a region to the same path are refused by name. +- A kind of file whose comment syntax the kit does not know, such as JSON, is refused. + +### Templates + +Files under `templates//` and `regions//` are rendered before they are placed. Each `@key@` +(autoconf's form) is replaced by the key's value. A template's result is then placed, digested, and merged exactly +like a `files/` file. Bash's `${var}` and GitHub's `${{ }}` cannot be mistaken for `@key@`, so a template can +contain both unchanged. + +The values come from the whole of `.kit/settings.yml`: + +- `@require_signing@` is a top-level setting. +- `@answers.ci_runner@` is an entry in a map. +- `@ci_runner@` is the entry under `answers:`. + +A key with no value in `.kit/settings.yml` takes the module's default from its own `answers`. A key with neither +fails the install, and the error names the key and the module. + +## Settings + +`.kit/settings.yml` belongs to the repository. The kit reads it and never writes it. + +- Its schema is [settings.schema.json](../modules/kit/files/repo/.kit/settings.schema.json), delivered beside it as + `.kit/settings.schema.json`. +- An editor validates the file through the modeline `# yaml-language-server: $schema=settings.schema.json`. +- Before every install, update, and check, the kit checks the file against the schema's main points, and + names any unknown key. + +```yaml +answers: + ci_runner: ubuntu-latest +``` + +| Key | Meaning | +| --- | --- | +| `forge` | The forge provider the workflow commands use, `github` or `gitlab`. The forge is the service that hosts the repository. When absent, it is detected from origin's host. Set it for a self-hosted forge. | +| `gate` | What `kit gate` runs. When absent, the kit detects a justfile `ci` recipe, a Makefile `ci` target, or a `package.json` `ci` script. | +| `require_signing` | `true` makes `kit issue` refuse to start when git has no `user.signingkey`. | +| `answers` | The values a module's templates use, overriding the module's own defaults. | +| `toolchains` | The tool managers that put the gate's tools on `PATH`: `mise`, `brew`, or `docker`; see [toolchains.md](toolchains.md). | +| `policy` | The patterns a branch, a commit subject, and a change title must match; see [policy.md](policy.md). | +| `policy_engine` | `patterns` or `opa`; see [policy.md](policy.md). | +| `trackers` | Trackers other than the forge's own whose issues the workflow accepts, each with a prefix and a provider; see [trackers.md](trackers.md). | + +## Modules a repository defines + +A directory `.kit/modules//` in the repository is a module in every respect. It has the same parts: +`module.yml`, `files//`, `templates//`, `regions//`, and `docs/`. + +- The kit looks up a module name in the release first, then in `.kit/modules/`. +- It is validated like a delivered module, and it may depend on delivered modules. +- Activate it with `kit install ` or `kit module add `. +- `kit module list` shows it with its origin. +- Its paths are recorded in the manifest with their digests, so `kit check` can tell an edited installed copy + from its source. +- On update, its files are compared with its own current source. An edit under `.kit/modules/` is never merged + against a release. +- `kit context` finds a rule file it places under `.kit/rules/` like any other rule. + +## This release's modules + +| Module | Default | Depends on | Delivers | +| --- | --- | --- | --- | +| `kit` | yes | | `.kit/1st.md`, `.kit/rules/.md`, `.kit/templates/`, `.kit/settings.schema.json`, `.kit/markdownlint.jsonc`; regions in `AGENTS.md` and `.gitignore` | +| `lefthook` | no | `kit` | `.kit/lefthook.yml`, a pre-commit hook running `kit lint` and `kit generate --check` | +| `ci-github` | no | `kit` | `.github/workflows/kit-ci.yml`, from a template, running `kit ci`; answer `ci_runner` | +| `claude` | no | `kit` | a region in `.claude/CLAUDE.md` importing `AGENTS.md`; `.claude/settings.json`, merged: the deny list and the `kit context --claude` hook | +| `policy-opa` | no | `kit` | `.kit/policy/kit.rego`, the naming policy in Rego, so `kit policy` runs on Open Policy Agent | + +No module in this release delivers to `home`. Each module's `docs/README.md` describes what it places and why. diff --git a/docs/policy.md b/docs/policy.md new file mode 100644 index 0000000..0b1c232 --- /dev/null +++ b/docs/policy.md @@ -0,0 +1,90 @@ +# Policy + +A policy is the set of patterns that a branch name, a commit subject, and a change's title must match. A change is +a pull request on GitHub or a merge request on GitLab. Run `kit policy` to check the current branch; it prints each +name that breaks the policy and fails. + +```sh +kit policy +``` + +- `kit policy` gathers the facts once, then the policy engine reports every refusal. +- `kit ci` runs it beside the gate (the repository's own CI command), so a name that breaks the policy fails the + delivered check like a failing test. +- On a runner, the refusals also appear in the step summary. + +## The facts + +`kit policy --input` prints the JSON document every engine reads: + +```json +{ + "branch": "7-fix-the-thing", + "default_branch": "main", + "commits": [{"sha": "56d4f4b…", "subject": "#7 cli: fix the thing"}], + "change": {"number": 3, "title": "Fix the thing", "body": "Closes #7"}, + "policy": {"branch": "…", "commit_subject": "…", "change_title": "…"} +} +``` + +| Field | Contents | +| --- | --- | +| `branch` | On a runner, the change's head branch. In the root checkout (the desk), the current branch. On the default branch or a detached checkout it is `null`, and nothing is checked. | +| `default_branch` | The repository's default branch. | +| `commits` | Each commit past the fork from the default branch at origin, merges excluded. The delivered workflow fetches full history for this. When the fork is not found, as in a shallow clone, no commit is checked and `kit policy` warns. | +| `change` | The change's number, title, and body, read through the forge (the service that hosts the repository). `null` when there is no forge or no change. | +| `policy` | The patterns, with the kit's defaults filled in. | + +## The rules + +`policy:` in `.kit/settings.yml` holds one extended regular expression per subject. + +- A subject that is absent takes the kit's default: the workflow's own naming from `.kit/1st.md`. +- An empty pattern turns that rule off. +- A subject the kit does not know is refused. + +| Subject | Checks | Default | +| --- | --- | --- | +| `branch` | the branch's name | `^[a-z0-9]+-[a-z0-9][a-z0-9-]*$`, the form `kit issue` creates | +| `commit_subject` | every commit's subject past the fork | `^([A-Z]+)?#[0-9]+ [^ ]`, starting with its issue's reference | +| `change_title` | the open change's title | `^[^[:cntrl:]]{1,120}$`, one line of at most 120 characters | + +```yaml +policy: + branch: '^(feature|fix)/[a-z0-9-]+$' + commit_subject: '' +``` + +A single-quoted pattern is taken literally. Write patterns in the subset that bash's extended regular expressions +and OPA's RE2 share, so both engines read them the same way: + +- character classes +- anchors +- alternation +- counted repetition + +## The engines + +| Engine | How it checks | Chosen by | +| --- | --- | --- | +| `patterns` | Matches each rule in bash. Needs no other tool. | Default. | +| `opa` | Open Policy Agent evaluates every `.rego` file under `.kit/policy/`, package `kit.policy`. The set `deny` of strings is the refusals. | `policy_engine: opa`, or any `.rego` file in `.kit/policy/`. | + +The `opa` engine needs `opa`. Pin it in the repository's toolchain. + +The `policy-opa` module delivers `.kit/policy/kit.rego`: the patterns engine's rules written in Rego, with the same +refusals. Because it is a `.rego` file, adding the module selects the `opa` engine, and the results stay the same. +Write a rule that a pattern cannot express in a separate `.rego` file beside it: + +```rego +package kit.policy + +import rego.v1 + +deny contains msg if { + count(input.commits) > 20 + msg := sprintf("%d commits; split the change", [count(input.commits)]) +} +``` + +`tests/policy.bats` checks that both engines give the same refusals for the same facts. diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..79c0292 --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,104 @@ +# Templates + +A template is a file of one kind with holes in it, attached to the rule for that kind. `kit new file` fills +the holes to make a new file, and `kit lint` checks that every existing file still has the shape of one of +its rule's templates. + +```sh +kit new file "${path}" --purpose="${purpose}" +``` + +## Where a template lives + +A rule lists its templates in its frontmatter, in the order they are tried. Each template is a variant of the file, +stored at `.kit/templates//`. A variant can be limited to one path glob (`path:`) or marked +executable (`executable: true`): + +```yaml +templates: + - variant: root + path: justfile + - variant: module +``` + +| Rule | Variant | Limited to | Notes | +| --- | --- | --- | --- | +| bash | `script` | | executable | +| bash | `module` | | a sourced file | +| just | `root` | `justfile` | | +| just | `module` | | | +| markdown | `agents` | `AGENTS.md` | | +| markdown | `document` | | | +| markdown | `notice` | | | +| markdown | `frontmatter` | | | +| yaml | `document` | | | +| workflows | `workflow` | | | +| gitignore | `file` | | | +| gitignore | `region` | `.gitignore` | a file the kit made of its region alone | + +The JSON rule has no template. A JSON file has no comments and no fixed opening, so there is no shape to check. + +The `kit` module delivers the templates. To change one, write a module of your own under `.kit/modules/` that +depends on `kit` and places a file at the same path. The later module in dependency order wins. +see: [modules.md](modules.md) + +## Holes + +A hole is a name between guillemets, `«name»`. No file kind the rules govern uses guillemets. + +| Hole | When making a file | When checking a file | +| --- | --- | --- | +| Within a line | Replaced by a value. | Matches any text on that line. A hole used twice must match the same text each time. | +| A line of its own | Replaced by the value's lines. With no value, one blank line, or nothing when it is the template's last line. | Matches any run of lines, including none. | + +For example, the just module template's `«stem»_justfile` appears twice, because it is the variable its help +recipe reads. + +`path`, `name`, and `stem` come from the path. For `bin/deploy.sh` they are `bin/deploy.sh`, `deploy.sh`, and +`deploy`. Every other hole within a line takes its value from the flag of the same name, such as `--purpose=<…>` +or `--title=<…>`. A missing flag is refused, and the error names it. + +## Making a file + +```sh +kit new file "${path}" [--rule="${rule}"] [--variant="${variant}"] [--"${hole}"="${value}"]… +``` + +- The path is relative to the repository root, wherever you type the command. A path outside the repository, or + one that already exists, is refused. Nothing is overwritten. +- The rule is the most specific rule that governs the path and has templates: the one whose matching glob has the + most characters other than wildcards. So `.github/workflows/check.yml` gets the workflows rule, not the YAML + rule. Use `--rule` when the path alone cannot select a rule, such as a script with no extension. +- The variant is the first one the rule lists that the path allows. Use `--variant` to pick another. +- An executable variant is made executable. +- The command then prints the rules to read before you write the rest, as `kit context` does, and records the call + in the context log. + +A new file is a starting point, not a finished file. For example, a workflow made from its template has empty +`on:`, `permissions:`, and `jobs:` keys, and actionlint rejects it until you fill them in. + +## Checking a file + +For each path governed by a rule with templates, `kit lint` checks that the file has the shape of one +variant its path allows. It does this in addition to running the rule's linter. When a file matches no +variant, `kit lint` reports: + +- the variants it was checked against +- the variant it follows furthest, and the line of that variant where the file stops matching + +```text +❌ bin/release.sh does not have the shape of the bash template (script, module); it leaves .kit/templates/bash/module line 2: # «name» - «purpose» +``` + +Three kinds of path are not checked against any template: + +- a template itself +- a module's region, which is a fragment of a file +- a path that `.gitattributes` marks `linguist-generated`, because a tool writes it + +```text +/.mise/locks/** linguist-generated +``` + +A template fixes the shape a rule requires: the lines a file must have, and their order. Style that varies line by +line, such as the inside of a recipe or a function, is left to the rule's linter and the rule's text. diff --git a/docs/toolchains.md b/docs/toolchains.md new file mode 100644 index 0000000..cba2d23 --- /dev/null +++ b/docs/toolchains.md @@ -0,0 +1,67 @@ +# Toolchains + +A toolchain is the tool manager that installs the tools the gate (the repository's own CI command) runs: mise, +Homebrew, or a Docker container image. `kit toolchain` installs, reports on, and updates those tools the same way +whichever manager the repository uses. + +```sh +kit toolchain install +``` + +`bin/toolchain.sh` defines the interface, and each manager has a provider, `bin/toolchain-.sh`, that +implements it. Every provider answers the same calls, so the root checkout (the desk), a CI runner, and the +delivered CI job report the same results whichever manager the repository uses. + +## Declaring + +A repository declares its toolchains in `.kit/settings.yml`, in order: + +```yaml +toolchains: + - provider: mise + - provider: brew + file: Brewfile + - provider: docker + file: toolchain.Dockerfile + image: example/toolchain +``` + +- With no declaration, the kit detects toolchains from the files they read, in the order mise, brew, docker. +- `file:` names a different file for brew or docker. +- `image:` names the docker image. The default is `kit-toolchain`. +- A provider the kit does not have is refused. + +| Provider | Detected from | Pins | Outdated when | +| --- | --- | --- | --- | +| `mise` | `mise.toml` or `.mise.toml` | each tool's version, resolved in `mise.lock` | `mise outdated` lists it | +| `brew` | `Brewfile` | a formula by name; a version only where the name contains it, `node@22` | `brew outdated` lists it | +| `docker` | `toolchain.Dockerfile` | each base image by digest, `FROM name:tag@sha256:…` | the registry's digest for the tag differs, or there is none | + +## Commands + +| Command | What it does | +| --- | --- | +| `kit toolchain list` | Prints each declared toolchain and the file it reads. | +| `kit toolchain install` | Installs every toolchain's pinned tools: `mise install`, `brew bundle install --no-upgrade`, or a `docker build` of the image. The image is tagged by the file's digest, so an unchanged file is never rebuilt. | +| `kit toolchain outdated` | Prints one line per tool that is behind its source. With `--check`, fails when there is one. | +| `kit toolchain update` | Moves every tool to its latest version and rewrites the pins: `mise upgrade`, `brew upgrade` of the Brewfile's outdated formulae, or new `FROM` digests in the Dockerfile. Commit the files it rewrote. | +| `kit toolchain path` | Prints the directories that put the tools first on `PATH`, for a runner's `GITHUB_PATH`. | +| `kit toolchain run …` | Runs a command with the tools. | + +`kit gate` runs the gate the same way: + +- Normally, with every toolchain's directories first on `PATH`. +- When docker is declared, inside the image, with the checkout mounted at `/work` and run as the caller's user, so + the files the gate writes belong to the caller. The image needs `bash`, because the gate runs as `bash -c`. + +## On a runner + +The delivered workflow runs `kit toolchain install`, then appends the output of `kit toolchain path` to the +runner's `PATH`. + +- Its one setup action puts the `mise` binary on the runner when the repository has a `mise.toml`. The action's + own install step is turned off. +- Homebrew and Docker are already on GitHub's hosted runners. +- The brew provider finds Homebrew at its own prefix when the runner leaves it off `PATH`. + +`tests/toolchain.bats` tests each provider against a fake of its tool. diff --git a/docs/trackers.md b/docs/trackers.md new file mode 100644 index 0000000..69d5f3b --- /dev/null +++ b/docs/trackers.md @@ -0,0 +1,76 @@ +# Trackers + +A tracker is the system that holds a repository's issues. It can be the forge (the service that hosts +the repository, GitHub or GitLab) or a separate system such as Azure DevOps. The syntax of an issue +reference selects its tracker: + +```sh +kit issue AB#4521 +``` + +The workflow commands look up issues through one interface, `bin/tracker.sh`. A repository can use more than one +tracker at once, for example GitHub issues for bugs and Azure DevOps for planned work. + +## References + +A reference names its tracker in its syntax, the way the trackers already write it: + +| Reference | Tracker | +| --- | --- | +| `7` or `#7` | the forge's own issues, GitHub's or GitLab's | +| `AB#4521` | an Azure DevOps work item, under a prefix the repository declares | + +`.kit/settings.yml` declares every tracker except the forge's own. Each record has: + +- `prefix`, the text a reference starts with +- `provider`, the provider that handles the reference +- the provider's own fields + +```yaml +trackers: + - prefix: "AB#" + provider: ado + organization: https://dev.azure.com/example + project: Claims + type: Task +``` + +`#` is the forge's own tracker unless a record claims it. A reference that matches no declared tracker is refused, +and the error lists the known prefixes. + +## Commands + +- `kit issue 7`, `kit issue AB#4521`, or both, primary first: + - Each issue is assigned to the caller as its tracker knows the caller. For Azure DevOps, that is the + identity `az` is signed in as. + - The branch name starts with the reference's key: `7-` or `ab4521-`. + - The forge links a branch to one of its own issues and closes the issue on merge. For any other issue, and + for every secondary issue, `kit issue` adds a `Closes ` line to the body of the change (the + pull request or merge request). GitHub's Azure Boards integration reads that line as a link. +- `kit linked` passes when the forge reports that the change closes an open issue of the repository. It also + passes when the change's body names an open issue of a declared tracker with a closing keyword: + `Closes AB#4521`, `Fixes AB#4521`, or `Resolves AB#4521`. +- `kit tidy` reads a worktree's issue from its branch's key. It removes the worktree when that issue is closed and + the branch has no change. + +## Providers + +| Provider | Tool | Fields | Closed when the state is | +| --- | --- | --- | --- | +| `forge` | the forge's own, through `bin/forge.sh` | none | the forge's closed state | +| `ado` | `az` with the `azure-devops` extension | `organization`, `project`, `type` (default `Task`) | Closed, Done, or Removed | + +A provider is `bin/tracker-.sh`. It implements these calls: + +| Call | Prints | +| --- | --- | +| `title ` | the issue's title | +| `state ` | `OPEN` or `CLOSED` | +| `assignees ` | the assignees' logins, space-separated | +| `assign ` | nothing; adds the assignee | +| `me` | the caller's login in this tracker, which can differ from the forge's | +| `create ` | the number of the issue it creates | +| `url <id>` | the issue's browser URL | + +`tests/tracker.bats` tests the Azure DevOps provider against a fake `az`. It cannot prove that a real organization's +`az` responds the way the fixtures do. diff --git a/justfile b/justfile new file mode 100644 index 0000000..fa337f4 --- /dev/null +++ b/justfile @@ -0,0 +1,97 @@ +#!/usr/bin/env just --justfile +# justfile - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +# The root justfile: the gate (just ci) and just refresh, which rebuilds this repository's installed copy of its +# own kit. For the workflow, use kit issue, rebase, tidy, and status, not the raw git commands. mise.toml puts bin/ +# on PATH. + +set dotenv-load := true +set positional-arguments := true + +# Recipes marked [script] run under this interpreter, as the just rule requires. It is named bare so it resolves +# through the PATH set below. bash comes from the machine and must be 4.4 or later, as for the kit itself. +set script-interpreter := ['bash'] + +# Put mise's pinned tool directories first on PATH, once, at parse time. Recipes then call each tool by its bare +# name and get the pinned version. mise-action puts the same directories on the runner's PATH, so a desk and the +# runner run the same tools. A directory already on PATH is not added again, so a nested just changes nothing. +export PATH := ``` + mise bin-paths 2>/dev/null | while IFS= read -r d + do + case ":${PATH}:" in (*":${d}:"*) ;; (*) printf '%s:' "${d}" ;; esac + done + ``` + env('PATH') + +# This file's own path, assigned at parse time so the body below needs no interpolation. +export root_justfile := source_file() + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "${root_justfile}" --list + +set export := true + +# The gate. It runs, in order: +# +# - kit lint over every tracked path +# - ajv: every module.yml and .kit/settings.yml against its schema +# - lychee: every link in the markdown, offline, including fragments +# - the bats suite +# - bin/kit check over this repository's installed copy of its own kit +# +# CI runs it on every pull request, and a desk runs it the same way. The pre-commit hook is a faster check over the +# staged paths only. +# +# just ci +[doc("🚦 The gate: kit lint, the schemas, the links, bats, kit check")] +[script] +ci: + set -o errexit -o nounset -o pipefail + bin/kit lint --all-files + ajv validate --spec=draft2020 -s modules/module.schema.json -d 'modules/*/module.yml' + ajv validate --spec=draft2020 -s .kit/settings.schema.json -d .kit/settings.yml + lychee --offline --include-fragments --no-progress README.md AGENTS.md docs modules .kit + bats --timing --print-output-on-failure tests/ + bin/kit check + echo "✅ ci passed" + +# Rebuild this repository's installed copies of its own kit from the index, and stage the result. In this +# repository the checkout is the release, so an edit under modules/ is a change to the release, not drift. +# +# The recipe works on the index, which is what the commit will contain: +# +# 1. Export the index. Export HEAD's bin/ and modules/ as the base release, the one the copies were last built +# from. +# 2. Run kit update over the exported index with that base, and stage the result. +# 3. Run kit update in the working tree too, so the desk's copies match the index in the parts kit owns. +# +# Unstaged edits outside a region, or outside kit's entries in a merged JSON file, are not staged. An entry the +# source removed is removed from the copy, because the base still has it. The pre-commit hook runs this when a +# commit touches the source. Run it by hand on a desk without the hook. +# +# just refresh +[doc("🔁 Bring this repository's own installed copies up to the index, and stage the result")] +[script] +refresh: + set -o errexit -o nounset -o pipefail + work=$(mktemp -d) + trap 'rm -rf "${work}"' EXIT + mkdir "${work}/index" "${work}/base" + git checkout-index --all --prefix="${work}/index/" + git archive HEAD bin modules | tar -x -C "${work}/base" + cp "$(git rev-parse --git-path index)" "${work}/index.file" + # The export is not a git repository. The ceiling stops git from finding this one above the temp directory. + (cd "${work}/index" && GIT_CEILING_DIRECTORIES="${work}" KIT_BASE="${work}/base" bin/kit update >/dev/null) + GIT_DIR=$(git rev-parse --absolute-git-dir) GIT_WORK_TREE="${work}/index" GIT_INDEX_FILE="${work}/index.file" \ + git -C "${work}/index" add --all + git read-tree "$(GIT_INDEX_FILE="${work}/index.file" git write-tree)" + KIT_BASE="${work}/base" "${work}/index/bin/kit" update >/dev/null + modules=$(sed -n 's/^modules *//p' .kit/manifest) + count=$(awk 'length($1) == 64' .kit/manifest | wc -l | tr -d ' ') + echo "✅ refreshed from the index and staged: ${count} delivered paths, modules ${modules}" + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..99e0e7e --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,19 @@ +# lefthook.yml - this repository's git hooks; see: https://lefthook.dev +# ---------------------------------------------------------------------------------------------------------------------- +# +# The linters come from the kit's installed .kit/lefthook.yml, as in any consumer repository: kit lint over the +# staged paths. This repository adds a step that refreshes its installed copies. Once per clone, run: +# lefthook install + +extends: + - .kit/lefthook.yml + +pre-commit: + commands: + # When a commit changes a delivered file's source, rebuild the installed copies in the same commit. The recipe + # stages what it rebuilt. + refresh: + glob: "modules/**" + run: just refresh + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/mise.desk.lock b/mise.desk.lock new file mode 100644 index 0000000..dc8663d --- /dev/null +++ b/mise.desk.lock @@ -0,0 +1,32 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.lefthook]] +version = "2.2.2" +backend = "aqua:evilmartians/lefthook" +specifiers = ["2.2"] + +[tools.lefthook."platforms.linux-arm64"] +checksum = "sha256:5e11944542e9889078d2300495882aa854d63854a664d09c5637df0bbc21c0e1" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_aarch64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518953" +provenance = "github-attestations" + +[tools.lefthook."platforms.linux-x64"] +checksum = "sha256:2b8d7fd52771f26d7a50260ef10cd6fe704d66f60102e566362b84da09884ab7" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_Linux_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518865" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-arm64"] +checksum = "sha256:aef03ff2faaf87594068ec3ab794dcc1acd8d64f2aa3109785244c8359fbf15b" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_arm64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518817" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-x64"] +checksum = "sha256:016e560b0cc982f45df14c301509e05b5c4265655eea996495093cd0c632063a" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.2.2/lefthook_2.2.2_MacOS_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/636518821" +provenance = "github-attestations" diff --git a/mise.desk.toml b/mise.desk.toml new file mode 100644 index 0000000..968a865 --- /dev/null +++ b/mise.desk.toml @@ -0,0 +1,6 @@ +# mise.desk.toml - the tools only a desk runs, read when MISE_ENV=desk; see: mise.toml +# +# Lefthook runs the pre-commit hook, and a runner commits nothing. + +[tools] +lefthook = "2.2" diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..9556d59 --- /dev/null +++ b/mise.lock @@ -0,0 +1,234 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.actionlint]] +version = "1.7.12" +backend = "aqua:rhysd/actionlint" +specifiers = ["1.7"] + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924897" +provenance = "github-attestations" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924896" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924893" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/rhysd/actionlint/releases/assets/384924880" +provenance = "github-attestations" + +[[tools.bats]] +version = "1.14.0" +backend = "aqua:bats-core/bats-core" +specifiers = ["1.14"] + +[tools.bats."platforms.linux-arm64"] +checksum = "sha256:bb537b70b15b732f6d8827dd6578e3d8ce166636ce1f18ea9a074184fcce9177" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" + +[tools.bats."platforms.linux-x64"] +checksum = "sha256:bb537b70b15b732f6d8827dd6578e3d8ce166636ce1f18ea9a074184fcce9177" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" + +[tools.bats."platforms.macos-arm64"] +checksum = "blake3:9b3dc5d296f3da85ce1062c7f9648c979cf2b982f0151f785eaa84d448dec779" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" + +[tools.bats."platforms.macos-x64"] +checksum = "sha256:bb537b70b15b732f6d8827dd6578e3d8ce166636ce1f18ea9a074184fcce9177" +url = "https://github.com/bats-core/bats-core/archive/refs/tags/v1.14.0.tar.gz" +url_api = "https://api.github.com/repos/bats-core/bats-core/tarball/v1.14.0" + +[[tools.gh]] +version = "2.103.2" +backend = "aqua:cli/cli" +specifiers = ["2.103"] + +[tools.gh."platforms.linux-arm64"] +checksum = "sha256:4c3e99a50bd8b181a3fe9d69b25dd978bac768148d88f01507c6b4d1ef61a443" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305069" +provenance = "github-attestations" + +[tools.gh."platforms.linux-x64"] +checksum = "sha256:62ccf747115ddc61237efffdde66a3c8f70ba249433dc64f70801089b59c59c8" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305062" +provenance = "github-attestations" + +[tools.gh."platforms.macos-arm64"] +checksum = "sha256:5416000f5d17580a187339e0c4068a01ca5c87f7d4fe6ca64933696f967bc418" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_macOS_arm64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305145" +provenance = "github-attestations" + +[tools.gh."platforms.macos-x64"] +checksum = "sha256:2dc7f1fc4761f96732995f6e4f4c6b4d705f2635da4126620c8b30eaa67b9c98" +url = "https://github.com/cli/cli/releases/download/v2.103.2/gh_2.103.2_macOS_amd64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/670305131" +provenance = "github-attestations" + +[[tools.jq]] +version = "1.8.2" +backend = "aqua:jqlang/jq" +specifiers = ["1.8"] + +[tools.jq."platforms.linux-arm64"] +checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756" +provenance = "github-attestations" + +[tools.jq."platforms.linux-x64"] +checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752" +provenance = "github-attestations" + +[tools.jq."platforms.macos-arm64"] +checksum = "sha256:2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012783" +provenance = "github-attestations" + +[tools.jq."platforms.macos-x64"] +checksum = "sha256:e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012782" +provenance = "github-attestations" + +[[tools.just]] +version = "1.59.0" +backend = "aqua:casey/just" +specifiers = ["1.59"] + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:23871b1702bfd550fb5f901778fd4290ecf2ee034a45a889fbe96d89b188086d" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642950086" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:409044617b6f06517c1035e55ca38c8c856ff8b650e41bc2aa1ef82d5903a23e" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/642949555" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:6574cea774ed4cae12e24b6aa11857de33e80dab4667cf628fd5840df96a2a9d" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/643129724" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:036ead9df9bc31dbae9934ae3b4236ed124d0bafcffd33439460ae084d5c9cb7" +url = "https://github.com/casey/just/releases/download/1.59.0/just-1.59.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/643131819" + +[[tools.lychee]] +version = "0.24.2" +backend = "aqua:lycheeverse/lychee" +specifiers = ["0.24"] + +[tools.lychee."platforms.linux-arm64"] +checksum = "sha256:91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c" +checksum_source = "mise-versions" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959442" + +[tools.lychee."platforms.linux-x64"] +checksum = "sha256:1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a" +checksum_source = "mise-versions" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959156" + +[tools.lychee."platforms.macos-arm64"] +checksum = "sha256:c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977" +checksum_source = "mise-versions" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409957951" + +[tools.lychee."platforms.macos-x64"] +checksum = "sha256:887503a9cff667d322b8d0892b40bf49976eb9507af8483220a3706cdad55978" +checksum_source = "mise-versions" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409957687" + +[[tools."npm:ajv-cli"]] +version = "5.0.0" +aube = { path = ".mise/locks/npm-ajv-cli/5.0.0", digest = "sha256:36d7da953aa2dac85d8e85af560d39cd2802e0b55dbc19ab9416b6ca0a1c3f9e" } +backend = "npm:ajv-cli" +specifiers = ["5"] + +[[tools."npm:markdownlint-cli2"]] +version = "0.24.0" +aube = { path = ".mise/locks/npm-markdownlint-cli2/0.24.0", digest = "sha256:689e86a3f956c317400e5d3bcf0915902a43dbbb8aeec7210a3aa3efb6859684" } +backend = "npm:markdownlint-cli2" +specifiers = ["0.24"] + +[[tools.opa]] +version = "1.21.4" +backend = "aqua:open-policy-agent/opa" +specifiers = ["1.21"] + +[tools.opa."platforms.linux-arm64"] +checksum = "sha256:810aa96e44f1f852d044888cf830324e44eafd0f510345339011347ab4deaab1" +checksum_source = "mise-versions" +url = "https://github.com/open-policy-agent/opa/releases/download/v1.21.4/opa_linux_arm64_static" +url_api = "https://api.github.com/repos/open-policy-agent/opa/releases/assets/669241510" + +[tools.opa."platforms.linux-x64"] +checksum = "sha256:2ab571f367f293bb19523b76f92f1945bbf245003392c7f392903f95c2b9b630" +checksum_source = "mise-versions" +url = "https://github.com/open-policy-agent/opa/releases/download/v1.21.4/opa_linux_amd64_static" +url_api = "https://api.github.com/repos/open-policy-agent/opa/releases/assets/669241335" + +[tools.opa."platforms.macos-arm64"] +checksum = "sha256:4c105208a9282f590385bccbe600875c011f0894ccf9431782dfe855e1f99aa6" +url = "https://github.com/open-policy-agent/opa/releases/download/v1.21.4/opa_darwin_arm64_static" +url_api = "https://api.github.com/repos/open-policy-agent/opa/releases/assets/669241240" + +[tools.opa."platforms.macos-x64"] +checksum = "sha256:8802bd39bc14b58a705e6b3d4f4dd14f7f24ee657f0362c737338be243190c7e" +checksum_source = "mise-versions" +url = "https://github.com/open-policy-agent/opa/releases/download/v1.21.4/opa_darwin_amd64" +url_api = "https://api.github.com/repos/open-policy-agent/opa/releases/assets/669241237" + +[[tools.shellcheck]] +version = "0.11.0" +backend = "aqua:koalaman/shellcheck" +specifiers = ["0.11"] + +[tools.shellcheck."platforms.linux-arm64"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-x64"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.macos-arm64"] +checksum = "sha256:56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056932" + +[tools.shellcheck."platforms.macos-x64"] +checksum = "sha256:3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056930" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..442e20e --- /dev/null +++ b/mise.toml @@ -0,0 +1,42 @@ +# mise.toml - the pinned tools a desk and a runner share; see: https://mise.jdx.dev/configuration/environments.html +# +# mise always reads this file. It also reads: +# +# - mise.desk.toml when MISE_ENV=desk, which a desk exports in its shell profile +# - mise.ci.toml when MISE_ENV=ci, which the CI workflow sets +# +# Put a tool in the file for whoever runs it. Lefthook runs only on a desk; no tool runs only on the runner. +# `lockfile = true` records each pin's exact version in a lock file beside its file. git and bash come from the +# machine. + +[settings] +lockfile = true +# A lock written on a desk also covers the runner's platforms; see: https://mise.jdx.dev/dev-tools/mise-lock.html +lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] + +# Put bin/ on PATH, so `kit` runs here the same way it runs in a consumer repository. +[env] +_.path = ["bin"] + +# Why each tool is here: +# actionlint the workflows rule's linter, over .github/workflows/, in kit lint and the gate +# bats runs tests/, the suite the gate runs +# gh the GitHub provider's tool: workflow commands, kit linked, kit release publish and retire +# jq reads and writes JSON: the merged .claude/settings.json, the forge's documents, kit status +# just runs the justfile: just ci (the gate) and just refresh (the installed copies) +# lychee checks every link in the markdown, offline, fragments included, in the gate +# ajv-cli validates every module.yml and .kit/settings.yml against their schemas, in the gate +# markdownlint-cli2 the markdown rule's linter, over every .md file, in kit lint and the gate +# opa the OPA policy engine; tests/policy.bats checks it matches the patterns engine +# shellcheck the bash rule's linter, over bin/ and tests/, in kit lint and the gate +[tools] +actionlint = "1.7" +bats = "1.14" +gh = "2.103" +jq = "1.8" +just = "1.59" +lychee = "0.24" +"npm:ajv-cli" = "5" +"npm:markdownlint-cli2" = "0.24" +opa = "1.21" +shellcheck = "0.11" diff --git a/modules/ci-github/docs/README.md b/modules/ci-github/docs/README.md new file mode 100644 index 0000000..d3a599e --- /dev/null +++ b/modules/ci-github/docs/README.md @@ -0,0 +1,19 @@ +# The ci-github module + +Delivers `.github/workflows/kit-ci.yml`: one GitHub Actions job, `CI`, that runs for each pull request into the +default branch. The job: + +1. Fetches the full history, which `kit policy` reads. +2. Puts `kit` on `PATH`. Where `.kit/manifest` says `unreleased`, it uses the kit's own checkout. Otherwise it + uses the release the manifest names. +3. Installs the gate's tools with `kit toolchain install`, whether mise, Homebrew, or a container image manages + them. The gate is the repository's own CI command, such as `just ci`. +4. Runs `kit ci`, which runs the gate, `kit check`, `kit linked`, and `kit policy`, and reports each one. + +The workflow has one setup action: `jdx/mise-action`, with its own install turned off. It puts the `mise` binary +on the runner when the repository has a `mise.toml`. GitHub's hosted runners already have Homebrew and Docker. + +A repository on another CI system calls `kit toolchain install` and `kit ci` from its own pipeline. + +The workflow is a template with one key, `ci_runner`: the label of the runner the job runs on. The default is +`ubuntu-latest`. A repository with its own runners sets `ci_runner` under `answers:` in `.kit/settings.yml`. diff --git a/modules/ci-github/module.yml b/modules/ci-github/module.yml new file mode 100644 index 0000000..4aa4f98 --- /dev/null +++ b/modules/ci-github/module.yml @@ -0,0 +1,9 @@ +# modules/ci-github/module.yml - the ci-github module: one GitHub Actions job that runs kit ci +# ---------------------------------------------------------------------------------------------------------------------- +# see: docs/modules.md + +description: "One GitHub Actions job that runs kit ci: the gate, kit check, and the linked-open-issue refusal" +depends: + - kit +answers: + ci_runner: ubuntu-latest diff --git a/modules/ci-github/templates/repo/.github/workflows/kit-ci.yml b/modules/ci-github/templates/repo/.github/workflows/kit-ci.yml new file mode 100644 index 0000000..e1fc752 --- /dev/null +++ b/modules/ci-github/templates/repo/.github/workflows/kit-ci.yml @@ -0,0 +1,82 @@ +# kit-ci.yml - the check the kit delivers for GitHub Actions: kit ci on every pull request into the default branch +# ---------------------------------------------------------------------------------------------------------------------- +# +# Runs one job, CI, on each pull request into the default branch. CI is the check name a ruleset requires. The job: +# +# 1. puts mise on the runner, when the repository has a mise.toml (brew and docker are already on GitHub's +# runners) +# 2. puts kit on PATH: the release .kit/manifest records, downloaded from its public URL, or this checkout's own +# bin/ when the manifest records unreleased +# 3. installs the gate's tools with `kit toolchain install`, whichever of mise, brew, or docker manages them +# 4. runs `kit ci`: the gate, `kit check`, `kit linked`, and `kit policy`, and reports every result +# +# Delivered by kit, not edited by hand. A change is a new release, then kit update. runs-on is the ci_runner value +# under answers: in .kit/settings.yml. +# +# see: .kit/1st.md + +name: kit + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + CI: + if: github.event.pull_request.base.ref == github.event.repository.default_branch + runs-on: "@ci_runner@" + env: + # mise also reads mise.ci.toml, on top of mise.toml, for the tools only a runner needs. + MISE_ENV: ci + steps: + - name: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # kit policy reads every commit since the branch forked from the default branch. + fetch-depth: 0 + + - name: mise-on-path + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + if: ${{ hashFiles('mise.toml', '.mise.toml') != '' }} + with: + install: false + + - name: kit-on-path + run: | + set -o errexit -o nounset -o pipefail + if [[ ! -f .kit/manifest ]] + then + echo '::error::run kit install and commit what it writes; this repository has no .kit/manifest' + exit 1 + fi + version=$(sed -n 's/^version //p' .kit/manifest) + if [[ "${version}" == unreleased && -x bin/kit ]] + then + printf '%s/bin\n' "${PWD}" >> "${GITHUB_PATH}" + elif [[ "${version}" == unreleased ]] + then + echo '::error::.kit/manifest says unreleased and there is no bin/kit; pin a release and run kit update' + exit 1 + elif ! command -v kit >/dev/null + then + mkdir -p "${RUNNER_TEMP}/kit" + curl --fail --silent --location \ + "https://github.com/empowerite/kit/releases/download/release/${version}/kit-${version}.tar.gz" \ + | tar --extract --gzip --directory "${RUNNER_TEMP}/kit" --strip-components 1 + printf '%s/kit/bin\n' "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + fi + + - name: toolchain + run: kit toolchain install && kit toolchain path >> "${GITHUB_PATH}" + + - name: kit-ci + env: + GH_TOKEN: ${{ github.token }} + run: kit ci + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/claude/docs/README.md b/modules/claude/docs/README.md new file mode 100644 index 0000000..33f4296 --- /dev/null +++ b/modules/claude/docs/README.md @@ -0,0 +1,26 @@ +# The claude module + +Adapts the kit for Claude Code. Claude Code reads `AGENTS.md` only as a fallback: when no `CLAUDE.md`, +`.claude/CLAUDE.md`, or `CLAUDE.local.md` is present in the working directory or above it, and where its +configuration allows. In a repository with its own `CLAUDE.md`, Claude Code would skip `AGENTS.md`. + +So the module delivers a region in `.claude/CLAUDE.md` that contains `@../AGENTS.md`. The region starts at +a `kit:generate` directive, and `kit generate` fills it. As a result: + +- Claude Code reads what every other agent reads, in every case. +- The rest of `.claude/CLAUDE.md`, and any root `CLAUDE.md`, belong to the repository. +- `kit check` checks only the region. + +The module also merges two entries into `.claude/settings.json`, by key, and leaves the repository's own entries +alone: + +- `permissions.deny`: the git commands that `.kit/1st.md` forbids an agent, because the kit's commands and the + workflow replace them. They are `git rebase`, `git worktree add`, `git checkout -b`, `git switch -c`, + `git worktree remove`, `git branch -D`, `git checkout main`, `git switch main`, `git reset --hard`, + `git push --force`, and `git push -f`. +- `hooks.PreToolUse`: on `Write`, `Edit`, and `MultiEdit`, runs `kit context --claude`, which shows the agent the + rules that govern the file. It is described in + [docs/context.md](https://github.com/empowerite/kit/blob/main/docs/context.md). + +`.claude/rules/` belongs to the repository. `AGENTS.md` contains no `@` import for Claude Code, because every +harness reads `AGENTS.md`. diff --git a/modules/claude/files/repo/.claude/settings.json b/modules/claude/files/repo/.claude/settings.json new file mode 100644 index 0000000..4845384 --- /dev/null +++ b/modules/claude/files/repo/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "permissions": { + "deny": [ + "Bash(git rebase *)", + "Bash(git worktree add *)", + "Bash(git checkout -b *)", + "Bash(git switch -c *)", + "Bash(git worktree remove *)", + "Bash(git branch -D *)", + "Bash(git checkout main *)", + "Bash(git switch main *)", + "Bash(git reset --hard *)", + "Bash(git push --force *)", + "Bash(git push -f *)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "kit context --claude" + } + ] + } + ] + } +} diff --git a/modules/claude/module.yml b/modules/claude/module.yml new file mode 100644 index 0000000..5821d0b --- /dev/null +++ b/modules/claude/module.yml @@ -0,0 +1,7 @@ +# modules/claude/module.yml - the claude module: Claude Code's deny list, and a hook that runs kit context +# ---------------------------------------------------------------------------------------------------------------------- +# see: docs/modules.md + +description: "Claude Code: the deny list of the spellings the rules forbid, and the hook that names a file's rules" +depends: + - kit diff --git a/modules/claude/regions/repo/.claude/CLAUDE.md b/modules/claude/regions/repo/.claude/CLAUDE.md new file mode 100644 index 0000000..dba71e9 --- /dev/null +++ b/modules/claude/regions/repo/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/modules/kit/docs/README.md b/modules/kit/docs/README.md new file mode 100644 index 0000000..9a61f2c --- /dev/null +++ b/modules/kit/docs/README.md @@ -0,0 +1,17 @@ +# The kit module + +The core module. It is active in every repository that uses the kit. It delivers: + +- `.kit/1st.md`, the entry file every agent reads. The repository's `AGENTS.md` points to it. +- `.kit/rules/<kind>.md`, one rule file per kind of file. Each rule file's frontmatter declares the paths and + first lines it governs, and the linter `kit lint` runs over them. +- A region in `AGENTS.md`: the sentence that sends every agent to `.kit/1st.md`. +- A region in `.gitignore`: the `/worktrees/` entry the workflow needs. + +Each region starts at a `kit:generate` directive, and `kit generate` fills it through `kit region`. The rest of +`AGENTS.md` and `.gitignore` belongs to the repository. + +Agents and people find the rules through `kit context`, described in +[docs/context.md](https://github.com/empowerite/kit/blob/main/docs/context.md). + +To add a kind of file, add one rule file here and one row to the table in `1st.md`. diff --git a/modules/kit/files/repo/.kit/1st.md b/modules/kit/files/repo/.kit/1st.md new file mode 100644 index 0000000..a55c780 --- /dev/null +++ b/modules/kit/files/repo/.kit/1st.md @@ -0,0 +1,175 @@ +<!-- Delivered by kit, not edited by hand. A change is a new release, then kit update. --> +# Read this first + +This file is the kit's entry point. Every agent follows it in every session, in any repository that has a `.kit/` +directory. The repository's own rules in `AGENTS.md` apply as well. Where the two differ, `AGENTS.md` wins. + +## Integrity + +1. Report only what happened. Write "done", "passing", or "verified" only for something that ran and succeeded. +2. Report a skip as a skip. A check that was not run, is not implemented, or cannot be implemented is reported as + exactly that. It passes no gate and earns no credit. +3. Give the evidence for every claim, or say there is none. Name the command that ran and what it printed. +4. Being wrong is recoverable. Misreporting is not. +5. Rules live in the repository, never in a session's memory. When you learn a convention or receive a correction, + write it down, through the workflow, in one of these places, where every agent and every person reads it: + - `AGENTS.md` + - a module of the repository's own under `.kit/modules/` + - a kit release + +## The root checkout is the human's desk + +The desk is the root checkout on the human's machine, on whatever branch it currently has. It belongs to the human. +They type, review, and leave work in progress there. It usually has `main` checked out, and may have any branch that +no worktree has checked out. + +An agent never checks out, resets, rebases, or otherwise changes the desk in place. An agent works in a worktree +under `worktrees/`, one per issue. Work reaches `main` only through a merged pull request. + +## The workflow + +1. Start from an issue. A change without an issue has not started. +2. `kit issue <N>` creates the following, and never switches the checkout you run it from: + - the worktree `worktrees/<N>-<slug>` + - a branch, linked to the issue under Development + - a draft pull request +3. Work in that worktree. Sign every commit. Write each commit subject as `#<N> <area>: <subject>`. Run + `kit rebase` to bring the branch up to date with the default branch and push it. +4. Run `gh pr ready` when the work is done. The pull request body is the report. It states: + - what changed + - what was checked, and how + - what was left out, and why +5. A human merges. Nothing reaches `main` any other way. Do not call anything merged until you see the merge. + +## Kit commands instead of git commands + +Some git commands have a kit replacement that adds the check its rule requires. An agent uses only the kit +command. A harness with a deny list refuses the raw git command. + +- `kit rebase`, never `git rebase`. `kit rebase` refuses to run in the root checkout or on the default branch. + Otherwise it rebases the worktree's branch onto the default branch at origin and pushes it. +- `kit issue`, never `git worktree add`, `git checkout -b`, or `git switch -c`. +- `kit tidy`, never `git worktree remove` or `git branch -D`. +- An agent never switches or resets the desk. `git checkout main`, `git switch main`, `git reset --hard`, and + `git push --force` are refused as well. + +These limits do not apply to the human at the desk. + +## Plans are prose + +Write a design, a spec, or a plan as prose in the body of an issue. Never commit it as a file. When the design is +written, the issue is the deliverable, and the workflow above starts from it. + +## The worker contract + +A worker is whoever implements one issue, in the worktree named for that issue. + +1. `cd` into the worktree named in the task. Confirm that `git rev-parse --abbrev-ref HEAD` prints a branch name + that begins with `<N>-`. +2. Run every command from there. Never touch the root checkout. +3. Never run `kit issue`, create a branch, or create a worktree. These were done before the handoff. +4. Implement what the issue asks and nothing else. +5. Push as you go. +6. Before you call anything done, run the repository's gate (its own CI command, such as `just ci`) through + the pinned toolchain. +7. Finish with `gh pr ready`, with the report in the pull request body. +8. If the gate fails or the work is partial, leave the pull request as a draft and say why. +9. Never merge. +10. Report what ran and what it printed. Nothing more. + +## Rules by file type + +Each kind of file has a rule file under `.kit/rules/`. The frontmatter at the top of a rule file has these keys: + +| Key | Contains | +| --- | --- | +| `paths:` | Globs matched against a file's path from the repository root. The rule governs a file that matches. | +| `content:` | Patterns matched against a file's first line. The rule governs a file that matches. | +| `lint:` | The linter that `kit lint` runs over the files the rule governs. | +| `templates:` | The shapes a file of this kind can take. The templates are under `.kit/templates/`. | + +Before you change a file: + +1. Run `kit context <path>`. It prints the rules that govern the file, and records that you asked. +2. Read every rule it prints. + +No harness keeps a copy of the rules. `kit context` is how every harness, and every person, finds them. + +Create a new file with `kit new file <path>`, giving the path from the repository root. Never write a new file +from nothing. `kit new file`: + +- writes the file from its rule's template +- fills the template's holes that the path supplies +- refuses a hole you left empty, and names the flag that fills it, such as `--purpose=<…>` or `--title=<…>` +- prints the rules to read before you write the rest of the file + +Two flags choose the template: + +- `--variant=<…>` picks another of the rule's shapes, such as a sourced bash module. +- `--rule=<…>` names the rule for a path with no extension. + +`kit lint` fails any file, including one written by hand, whose shape matches none of its rule's templates. + +| Kind | Rule | +| --- | --- | +| Markdown | [rules/markdown.md](rules/markdown.md) | +| Bash | [rules/bash.md](rules/bash.md) | +| Just | [rules/just.md](rules/just.md) | +| YAML | [rules/yaml.md](rules/yaml.md) | +| Workflows | [rules/workflows.md](rules/workflows.md) | +| JSON | [rules/json.md](rules/json.md) | +| Ignore lists | [rules/gitignore.md](rules/gitignore.md) | + +## What the kit delivers + +The repository contains the text: + +- this file +- the rules under `.kit/rules/` +- `.kit/manifest`, which records the release version, the active modules, and a checksum for each delivered file + +The installed release contains the code. You run it through the `kit` command on `PATH`. The workflow commands +are `kit issue`, `kit rebase`, `kit tidy`, `kit status`, `kit context`, `kit new file`, and `kit lint`. Run them +exactly as written here, and in no other way. + +Everything the release delivers belongs to a module. A repository activates the modules it wants: + +- `kit install ci-github lefthook claude` activates modules when the kit is installed. +- `kit module add claude` activates a module later. +- `kit module remove` deactivates a module. + +A module of the repository's own lives under `.kit/modules/` and is activated the same way. + +How a delivered file is placed depends on its destination: + +- A `.json` file, other than a schema, is merged by key. The kit owns only the entries it writes. +- A region inside a file the repository owns starts at a `kit:generate` directive. `kit generate` fills the region + by running the command the directive names. +- Every other file is copied as it is. + +The modules: + +- `kit`: this file, the rules, and their templates, and two regions that `kit region` fills: + - the sentence in the repository's `AGENTS.md` that points to this file + - the `/worktrees/` entry in the repository's `.gitignore` +- `lefthook`: `.kit/lefthook.yml`, a pre-commit hook that runs `kit lint` and `kit generate --check` over the + staged paths. A repository's `lefthook.yml` includes it with `extends` and adds its own commands. `kit lint` + does not need a hook manager: it runs each rule's linter over the paths the rule governs. +- `ci-github`: `.github/workflows/kit-ci.yml`, one GitHub Actions job. The job installs the gate's tools with + `kit toolchain install`, then runs `kit ci`. `kit ci` runs four checks, and runs the same four on any machine: + - the repository's gate + - `kit check` + - `kit linked`, which refuses a change that is not linked to an open issue + - `kit policy`, which refuses a branch name, commit subject, or title that breaks the repository's policy +- `policy-opa`: `.kit/policy/kit.rego`, the naming policy in Rego. With it, `kit policy` runs on Open Policy + Agent, and a rule can express what a pattern cannot. +- `claude`: support for Claude Code, which reads `AGENTS.md` only when it finds no `CLAUDE.md`. It delivers: + - a region in `.claude/CLAUDE.md` that imports `AGENTS.md`, so Claude Code reads `AGENTS.md` in every case + - the deny list above, merged into `.claude/settings.json` + - a hook that runs `kit context` before every write, so the agent sees the rules without asking + +Any file may also contain a `kit:generate` directive of the repository's own, naming any command. `kit check` fails +when any region differs from what its command produces. + +`kit check` fails when a delivered file differs from the pinned release. Change the kit's files by pinning a new +release and running `kit update`, never by editing them in place. diff --git a/modules/kit/files/repo/.kit/markdownlint.jsonc b/modules/kit/files/repo/.kit/markdownlint.jsonc new file mode 100644 index 0000000..7d880aa --- /dev/null +++ b/modules/kit/files/repo/.kit/markdownlint.jsonc @@ -0,0 +1,20 @@ +// .kit/markdownlint.jsonc - the markdownlint configuration the markdown rule's linter reads +// ---------------------------------------------------------------------------------------------------------------------- +// Delivered by kit, not edited by hand. A change is a new release, then kit update. The markdown rule, .kit/rules/markdown.md, +// is the convention; each setting here is one of its lines the linter can hold. + +{ + // MD003 heading-style: ATX headings, the rule's §Layout 5. + "MD003": { "style": "atx" }, + + // MD013 line-length: prose wraps at 118, 120 being the ceiling; code and tables do not wrap (§Layout 1 and 3). + "MD013": { "line_length": 120, "code_blocks": false, "tables": false }, + + // MD041 first-line-heading: a delivered file opens with its notice, AGENTS.md with the sentence a harness reads + // first (§Layout 5). + "MD041": false, + + // MD060 table-column-style: tables use " | " with spaces around each pipe (§Layout 7); the check is stricter + // than the rule and is off. + "MD060": false +} diff --git a/modules/kit/files/repo/.kit/rules/bash.md b/modules/kit/files/repo/.kit/rules/bash.md new file mode 100644 index 0000000..a45543a --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/bash.md @@ -0,0 +1,66 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.sh" + - "**/*.bash" +content: + - "^#!.*/(env +)?(ba)?sh( |$)" +lint: shellcheck --shell=bash --external-sources +templates: + - variant: script + executable: true + - variant: module +--- +# Bash + +Applies to: + +- `*.sh` and `*.bash` files +- recipe bodies under `#!/usr/bin/env bash` +- `run:` blocks in workflows + +## Shape + +1. An executed script opens with these lines, in order: + 1. `#!/usr/bin/env bash` + 2. a `# <file> - <purpose>` banner under 120 columns + 3. a line of dashes to column 120 + 4. a blank line + 5. an optional comment paragraph + 6. `set -o errexit -o nounset -o pipefail` +2. A sourced module opens with `# shellcheck shell=bash`, then the same banner. It sets no shell options, because + they would change the caller's options. +3. The file ends with the same line of dashes. + +## Style + +1. `then`, `do`, and `else` go on their own line, never after a semicolon. +2. Spell `set` options out: `set -o errexit`, never `set -e`. +3. Use a tool's long options where every platform accepts them: `--raw-output`, `--field`, `--quiet`. Where BSD + and GNU differ, use the short form, which both accept: `sed -n`, `head -n 1`, `mkdir -p`, `rm -f`, `cut -d`, + `cp -R`, `find -type`, `read -r`, and `git -C`. +4. Arithmetic uses `(( ))`. String and file tests use `[[ ]]`. +5. Brace every variable longer than one character: `${name}`. `$1`, `$@`, `$?`, and `$#` stay bare. +6. A `case` label has both parentheses: `(vanilla)`. The last group has no `;;`. +7. Quote every expansion outside `[[ ]]` and `(( ))`. +8. Name a function in a sourced module `<module>:<kebab-name>`, where `<module>` is the file's basename. The + loader, `1st.sh`, is the exception. Its verbs have bare names (`use`, `info`, `step`, `ok`, `section`, `warn`, + `error`, and `summary`), and its helpers use the `out:` and `guard:` prefixes. +9. Name a function private to one executed script in bare kebab-case. + +## Behavior + +1. An error message is one line. It names the remedy, if any. The same condition gives the same message everywhere. +2. A helper that refuses returns non-zero and lets the caller end the script. It does not call `exit` itself. +3. A secret never appears in `argv`. Pipe it: `printf '%s' "${secret}" | gh secret set NAME`. +4. Diagnostics go to stderr. Stdout is for data a caller can pipe. +5. A command that changes a remote, a branch, or a worktree is guarded by a check that names what it is about to + do. If the change removes work, as a sweep of worktrees or branches does, the command also has `--dry-run`. +6. Read the default branch from `origin/HEAD`, with `main` as the fallback. Never hard-code it. +7. shellcheck passes with no directive. Where a directive is needed, its reason is on the same line. + +## Comments + +1. A comment states what the code does and any non-obvious reason for it. It contains no history and does not + defend an obvious choice. +2. A pointer is `# see: <ref>`, on its own line after the comment it supports. diff --git a/modules/kit/files/repo/.kit/rules/gitignore.md b/modules/kit/files/repo/.kit/rules/gitignore.md new file mode 100644 index 0000000..505efbe --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/gitignore.md @@ -0,0 +1,34 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - ".gitignore" + - "**/.gitignore" +templates: + - variant: file + - variant: region + path: .gitignore +--- +# Ignore lists + +Applies to `.gitignore` at any depth. + +## Shape + +1. A file the repository writes opens with `# .gitignore - untracked files and folders`, a line of dashes to + column 120, and a blank line. A file the kit created that contains only the kit's region belongs to the kit + until the repository adds to it. +2. Group the entries. Each group opens with `# <what produces it>; see: <ref>` and ends with a blank line. +3. The file ends with a line of dashes. + +## Entries + +1. Every entry names a file the repository itself produces. Editor and operating-system files belong in the + user's global ignore list, not here. +2. Put an entry in the deepest directory where it applies. The root `.gitignore` contains only patterns that apply + across the whole repository. +3. A pattern that names a directory ends with `/`. A pattern anchored to the file's directory starts with `/`. +4. Ignore `/worktrees/` at the root. The workflow checks out a branch for each issue there. + +## Adding an entry + +Ask what produces the file. If nothing in the repository does, do not add it. diff --git a/modules/kit/files/repo/.kit/rules/json.md b/modules/kit/files/repo/.kit/rules/json.md new file mode 100644 index 0000000..c6430da --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/json.md @@ -0,0 +1,27 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.json" + - "**/*.jsonc" +--- +# JSON + +Applies to `*.json` and `*.jsonc`. + +## Shape + +1. Indent with two spaces. Keep keys in a stable order: as the consuming tool documents, else alphabetical. +2. End with one trailing newline. No trailing commas in `.json`. +3. Where the consuming tool reads JSONC, name the file `.jsonc` and comment every non-obvious setting. Where it + reads plain JSON, explain a setting in the document that describes the file. + +## Settings + +1. A disabled linter rule has a comment that names the rule and the convention it conflicts with. +2. In a merged settings file, do not edit an entry the kit wrote. Change it through a release and `kit update`. + The repository may edit everything else in the file. + +## Comments + +1. A comment states what the entry does and any non-obvious reason for it. It contains no history. +2. A pointer is `// see: <ref>`. diff --git a/modules/kit/files/repo/.kit/rules/just.md b/modules/kit/files/repo/.kit/rules/just.md new file mode 100644 index 0000000..0a74945 --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/just.md @@ -0,0 +1,82 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "justfile" + - "**/justfile" + - "**/*.just" +content: + - "^#!.*/(env +)?just( |$)" +templates: + - variant: root + path: justfile + - variant: module +--- +# Just + +Applies to `justfile` and `*.just`. A `[script]` recipe body is bash, so the bash rule also applies to it. + +## Shape + +1. Every justfile opens with `# <file> - see: https://just.systems/man/en/`, a line of dashes to column 120, + and a description of what the file is and who imports it. The root justfile has the + `#!/usr/bin/env just --justfile` shebang above those lines. A module does not. +2. The settings block comes next: + - `set dotenv-load := true` + - `set fallback := true`, only in a module, never in the root, because the root has no parent + - `set positional-arguments := true` + - `set script-interpreter := ['bash']`, with the interpreter named bare so the pinned bash is used + + `set export := true` comes after the default-and-help pair, so that the pair is identical in every justfile. +3. The root justfile puts the mise-pinned tools first on `PATH`, once, at parse time, from `mise bin-paths`. + Every recipe then calls its tools by bare name, and the pinned tool runs instead of any other copy, both + locally and on a CI runner: + + ````just + export PATH := ``` + mise bin-paths 2>/dev/null | while IFS= read -r d + do + case ":${PATH}:" in (*":${d}:"*) ;; (*) printf '%s:' "${d}" ;; esac + done + ``` + env('PATH') + ```` + +4. Every justfile has the default-and-help pair, with the file's own path in an exported variable: + + ```just + # This file's own path, assigned at parse time so the body below needs no interpolation. + export <name>_justfile := source_file() + + # Default recipe: list this justfile's recipes. + _: help + + # List recipes in this justfile. + [no-exit-message] + @help *options: + just --justfile "${<name>_justfile}" --list + ``` + +5. In a module the root imports with `mod <name> "<path>"`, every recipe has `[no-cd]`. The recipe body then + runs from the invoking root, not from the module's directory. +6. The file ends with a line of dashes. + +## Recipes + +1. Every recipe except the default-and-help pair has `[doc("<emoji> <short, action-first description>")]`. + Related recipes share an emoji. +2. A comment block precedes every recipe: a paragraph of intent, then the usage lines. The comment states what + the recipe does, when to run it, and what it produces. +3. Write a non-trivial body as a `[script]` recipe, never a shebang recipe. Its first line is + `set -o errexit -o nounset -o pipefail`. Call a tool in it by bare name, never through `mise exec`. +4. A body shows what runs. Move logic longer than a dozen lines into a bash script that the body calls. +5. Use no `{{...}}` interpolation anywhere, in a body or a comment. Pass a value that `just` computes to a body as + an exported variable: `export <name> := <expression>`. `set export := true` exports every parameter. A body + refers to values only as `${name}`. +6. Indent a body with two spaces. Never use a tab. +7. Spell out recipe and module names. Never abbreviate them. A second `mod` at the same target is an alias. +8. Comments wrap at 118 columns. Bodies do not wrap. + +## Invocation + +1. Call a module's recipe with spaces: `just tools check`. Never write `just tools/check`, and never write + `tools::check` outside a dependency list. +2. A recipe that mutates a remote or a worktree names what it is about to do before doing it. diff --git a/modules/kit/files/repo/.kit/rules/markdown.md b/modules/kit/files/repo/.kit/rules/markdown.md new file mode 100644 index 0000000..aa3f6a3 --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/markdown.md @@ -0,0 +1,60 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.md" +lint: markdownlint-cli2 --config .kit/markdownlint.jsonc +templates: + - variant: agents + path: AGENTS.md + - variant: document + - variant: notice + - variant: frontmatter +--- +# Markdown + +Applies to `*.md`. + +## Prose + +1. Write plain declarative sentences, with the thing under discussion as the subject. +2. Describe the current state. Leave the history to git: no "previously", "used to", "replaced", or "ported from". +3. Use a list for a list, a procedure, or a workflow, so it scans. Use a table where the content has columns. +4. Do not bold words for emphasis. Do not open a list item with a bolded term and a colon. +5. Do not end a paragraph on a slogan. Do not restate what the code or config already states. +6. Use the serial comma in every list of three or more. +7. Expand an uncommon acronym the first time it appears. +8. A pointer is `see: <ref>`, where `<ref>` is a URL, a repository-relative path, or `issue #N`. Put it inline + when it keeps the sentence on one line. Otherwise put it on its own line at the end of the paragraph. + +## Snippets + +1. Write a value the reader supplies as a shell variable that the prose names, such as `"${signing_key}"`, never + as an angle-bracket placeholder. The block then works when pasted after one `export`. +2. Where a reader may lack a prerequisite, link the page that creates it beside the command that uses it. + +## Layout + +1. Wrap prose at 118 columns, with 120 as the ceiling. Inside a list item, pull the right margin in by 4 + columns per nesting level, to a limit of 12. +2. Balance the wrap: no line, especially the last, is a short stub. +3. Do not wrap fenced code, tables, HTML comments, or a line that contains only a link. +4. Keep a list tight or loose as it was written. Reflowing never changes one into the other. +5. Use ATX headings (`#`). Use one `#` heading per file, at the top. `AGENTS.md` is the exception: it opens with + the sentence a harness reads first. +6. A fenced code block declares a language and has a blank line before and after it. +7. Table rows use ` | ` with spaces around each pipe, including the delimiter row. + +## Links + +1. Use inline links with descriptive text, never a bare URL in angle brackets. +2. Use the same link text every time the same URL appears in a file. +3. When a label and the link text would be the same, make the label the link. +4. A relative link resolves from the file that contains it, and it must resolve. + +## Files + +1. Name an index file `1st.md`. It lists its siblings and states what each is for. +2. The first paragraph of a file states what its subject is, before explaining how to use it. +3. A README states what the repository is. It has no Status section and no paragraph on what the repository + will do or does not yet do. +4. A delivered file does not name any repository as the source of a rule. diff --git a/modules/kit/files/repo/.kit/rules/workflows.md b/modules/kit/files/repo/.kit/rules/workflows.md new file mode 100644 index 0000000..3a9400b --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/workflows.md @@ -0,0 +1,34 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" +lint: actionlint +templates: + - variant: workflow +--- +# Workflows + +Applies to GitHub Actions workflows under `.github/workflows/`. The YAML rule also applies to them. + +## Shape + +1. The file opens with a comment: what the workflow verifies or produces, when it runs, and the one thing a + reader would not guess from the keys. +2. Keys are in the order GitHub documents them: `name`, `on`, `permissions`, `jobs`. +3. Use one job per scope and one step per policy. A job's key is the check name a ruleset requires, so renaming + the job breaks the requirement. +4. Declare `permissions` at the top, granting the least the jobs need. + +## Steps + +1. Every `uses:` pins a full commit SHA, with the version as a trailing comment: `@<sha> # v1.2.3`. +2. Every step has a `name`: the policy it enforces, in kebab-case. +3. A `run:` block is bash and follows the bash rule: `set -o errexit -o nounset -o pipefail` first. +4. A refusal prints `::error::<message>` naming the remedy, and returns non-zero. If a reader will act on the + refusal, it also writes `### ❌ <Message>` and the remedy to `${GITHUB_STEP_SUMMARY}`. +5. A step whose result a reader looks for writes one `✅` line to the summary. +6. A later step that must run after an earlier refusal has `if: ${{ !cancelled() }}`. +7. A tool gets on `PATH` because the repository pins it and a step installs it, never through `curl` in a step. + The one exception is the kit itself. The delivered job downloads the release that `.kit/manifest` names, + because nothing else can install the kit first. diff --git a/modules/kit/files/repo/.kit/rules/yaml.md b/modules/kit/files/repo/.kit/rules/yaml.md new file mode 100644 index 0000000..01f2215 --- /dev/null +++ b/modules/kit/files/repo/.kit/rules/yaml.md @@ -0,0 +1,22 @@ +--- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +paths: + - "**/*.yml" + - "**/*.yaml" +templates: + - variant: document +--- +# YAML + +Applies to `*.yml` and `*.yaml`. A GitHub Actions workflow also follows the workflows rule. + +## Shape + +1. The file opens with a comment: what it configures, and the one thing a reader would not guess from the keys. +2. Indent with two spaces. No tabs. No trailing whitespace. +3. Quote a scalar that contains a colon followed by a space, or that starts with a reserved character. + +## Comments + +1. A comment states the non-obvious reason. It contains no history. +2. A pointer is `# see: <ref>`. diff --git a/modules/kit/files/repo/.kit/settings.schema.json b/modules/kit/files/repo/.kit/settings.schema.json new file mode 100644 index 0000000..906a759 --- /dev/null +++ b/modules/kit/files/repo/.kit/settings.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/empowerite/kit/.kit/settings.schema.json", + "title": ".kit/settings.yml", + "description": "The repository's settings. The kit reads this file and never writes it. This schema is delivered by kit, not edited by hand. Each key is described in https://github.com/empowerite/kit/blob/main/docs/modules.md. To have an editor validate the file against this schema, add the modeline: # yaml-language-server: $schema=settings.schema.json", + "type": "object", + "additionalProperties": false, + "properties": { + "forge": { + "type": "string", + "enum": [ + "github", + "gitlab" + ], + "description": "The forge (the service that hosts the repository) the workflow commands use: github or gitlab. When absent, it is detected from origin's host, github.com or gitlab.com. A self-hosted forge must be set here." + }, + "gate": { + "type": "string", + "minLength": 1, + "description": "The command kit gate runs. When absent, kit gate detects a root justfile ci recipe, a Makefile ci target, or a package.json ci script." + }, + "require_signing": { + "type": "boolean", + "description": "When true, kit issue fails before writing anything if git has no signing key configured." + }, + "answers": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z_]+$": { + "type": "string", + "description": "The value a module's templates use for this key, written @key@ in a template." + } + } + }, + "trackers": { + "type": "array", + "description": "The issue trackers the workflow uses in addition to the forge's own issues. The forge's tracker handles the # prefix unless an entry here claims it. A reference names its tracker by its prefix: #7, AB#4521.", + "items": { + "type": "object", + "required": [ + "prefix", + "provider" + ], + "additionalProperties": false, + "properties": { + "prefix": { + "type": "string", + "pattern": "^[A-Za-z]*#?$", + "description": "The text a reference to this tracker starts with, before the number: AB# for Azure DevOps work items." + }, + "provider": { + "type": "string", + "enum": [ + "forge", + "ado" + ], + "description": "The tracker that handles the prefix: forge for the forge's own issues, ado for Azure DevOps work items." + }, + "organization": { + "type": "string", + "description": "ado: the organization's URL, https://dev.azure.com/<name>." + }, + "project": { + "type": "string", + "description": "ado: the project a new work item is created in." + }, + "type": { + "type": "string", + "description": "ado: the type of a new work item. Default: Task." + } + } + } + }, + "toolchains": { + "type": "array", + "description": "The toolchains that put the gate's tools on PATH, in order. When absent, they are detected from mise.toml, Brewfile, and toolchain.Dockerfile.", + "items": { + "type": "object", + "required": [ + "provider" + ], + "additionalProperties": false, + "properties": { + "provider": { + "type": "string", + "enum": [ + "mise", + "brew", + "docker" + ], + "description": "The tool manager: mise, brew, or docker." + }, + "file": { + "type": "string", + "description": "brew and docker: the file the tool manager reads. Default: Brewfile or toolchain.Dockerfile." + }, + "image": { + "type": "string", + "description": "docker: the name of the built image. Default: kit-toolchain." + } + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "description": "The patterns that the branch name, commit subjects, and pull request title must match. Each is an extended regular expression, limited to the subset RE2 also supports. An absent key uses the kit's default. An empty pattern turns that rule off.", + "properties": { + "branch": { + "type": "string", + "description": "The pattern for the branch name. Default: ^[a-z0-9]+-[a-z0-9][a-z0-9-]*$, the shape kit issue gives a branch." + }, + "commit_subject": { + "type": "string", + "description": "The pattern for the subject of every commit since the branch forked. Default: ^([A-Z]+)?#[0-9]+ [^ ], a subject that starts with its issue's reference." + }, + "change_title": { + "type": "string", + "description": "The pattern for the open pull request's title. Default: ^[^[:cntrl:]]{1,120}$." + } + } + }, + "policy_engine": { + "type": "string", + "enum": [ + "patterns", + "opa" + ], + "description": "The policy engine: patterns, matched in bash, or opa, which evaluates the Rego under .kit/policy. When absent, opa is used if a .rego file is there, and patterns otherwise." + } + } +} diff --git a/modules/kit/files/repo/.kit/templates/bash/module b/modules/kit/files/repo/.kit/templates/bash/module new file mode 100644 index 0000000..b4c4b63 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/bash/module @@ -0,0 +1,5 @@ +# shellcheck shell=bash +# «name» - «purpose» +# ---------------------------------------------------------------------------------------------------------------------- +«body» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/kit/files/repo/.kit/templates/bash/script b/modules/kit/files/repo/.kit/templates/bash/script new file mode 100644 index 0000000..ddfdf99 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/bash/script @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# «name» - «purpose» +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set -o errexit -o nounset -o pipefail +«body» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/kit/files/repo/.kit/templates/gitignore/file b/modules/kit/files/repo/.kit/templates/gitignore/file new file mode 100644 index 0000000..904cc2f --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/gitignore/file @@ -0,0 +1,4 @@ +# .gitignore - untracked files and folders +# ---------------------------------------------------------------------------------------------------------------------- +«entries» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/kit/files/repo/.kit/templates/gitignore/region b/modules/kit/files/repo/.kit/templates/gitignore/region new file mode 100644 index 0000000..083cc3e --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/gitignore/region @@ -0,0 +1,3 @@ +# kit:generate .gitignore +«entries» +# kit:end .gitignore diff --git a/modules/kit/files/repo/.kit/templates/just/module b/modules/kit/files/repo/.kit/templates/just/module new file mode 100644 index 0000000..5c3a322 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/just/module @@ -0,0 +1,22 @@ +# «name» - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set dotenv-load := true +set fallback := true +«settings» +set positional-arguments := true +«settings» +set script-interpreter := ['bash'] +«settings» +# This file's own path, assigned at parse time so the body below needs no interpolation. +export «stem»_justfile := source_file() + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "${«stem»_justfile}" --list +«recipes» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/kit/files/repo/.kit/templates/just/root b/modules/kit/files/repo/.kit/templates/just/root new file mode 100644 index 0000000..8e63eec --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/just/root @@ -0,0 +1,22 @@ +#!/usr/bin/env just --justfile +# justfile - see: https://just.systems/man/en/ +# ---------------------------------------------------------------------------------------------------------------------- +«about» +set dotenv-load := true +«settings» +set positional-arguments := true +«settings» +set script-interpreter := ['bash'] +«settings» +# This file's own path, assigned at parse time so the body below needs no interpolation. +export root_justfile := source_file() + +# Default recipe: list this justfile's recipes. +_: help + +# List recipes in this justfile. +[no-exit-message] +@help *options: + just --justfile "${root_justfile}" --list +«recipes» +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/modules/kit/files/repo/.kit/templates/markdown/agents b/modules/kit/files/repo/.kit/templates/markdown/agents new file mode 100644 index 0000000..df26dab --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/markdown/agents @@ -0,0 +1 @@ +«body» diff --git a/modules/kit/files/repo/.kit/templates/markdown/document b/modules/kit/files/repo/.kit/templates/markdown/document new file mode 100644 index 0000000..7a12cfe --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/markdown/document @@ -0,0 +1,2 @@ +# «title» +«body» diff --git a/modules/kit/files/repo/.kit/templates/markdown/frontmatter b/modules/kit/files/repo/.kit/templates/markdown/frontmatter new file mode 100644 index 0000000..5dea690 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/markdown/frontmatter @@ -0,0 +1,4 @@ +--- +«frontmatter» +--- +«body» diff --git a/modules/kit/files/repo/.kit/templates/markdown/notice b/modules/kit/files/repo/.kit/templates/markdown/notice new file mode 100644 index 0000000..aa92f12 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/markdown/notice @@ -0,0 +1,3 @@ +<!-- «notice» --> +# «title» +«body» diff --git a/modules/kit/files/repo/.kit/templates/workflows/workflow b/modules/kit/files/repo/.kit/templates/workflows/workflow new file mode 100644 index 0000000..0eefc0a --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/workflows/workflow @@ -0,0 +1,10 @@ +# «purpose» +«about» +name: «workflow» +«body» +on: +«triggers» +permissions: +«permissions» +jobs: +«jobs» diff --git a/modules/kit/files/repo/.kit/templates/yaml/document b/modules/kit/files/repo/.kit/templates/yaml/document new file mode 100644 index 0000000..9b74360 --- /dev/null +++ b/modules/kit/files/repo/.kit/templates/yaml/document @@ -0,0 +1,2 @@ +# «purpose» +«body» diff --git a/modules/kit/module.yml b/modules/kit/module.yml new file mode 100644 index 0000000..21e6a07 --- /dev/null +++ b/modules/kit/module.yml @@ -0,0 +1,6 @@ +# modules/kit/module.yml - the kit module: the agents' entry point, the rules for each file kind, and kit context +# ---------------------------------------------------------------------------------------------------------------------- +# see: docs/modules.md + +description: "The entry every agent reads, the rules by file kind, and the context call that names them" +default: true diff --git a/modules/kit/regions/repo/.gitignore b/modules/kit/regions/repo/.gitignore new file mode 100644 index 0000000..195dd81 --- /dev/null +++ b/modules/kit/regions/repo/.gitignore @@ -0,0 +1,2 @@ +# per-issue worktrees for agents; see: .kit/1st.md +/worktrees/ diff --git a/modules/kit/regions/repo/AGENTS.md b/modules/kit/regions/repo/AGENTS.md new file mode 100644 index 0000000..a961ab1 --- /dev/null +++ b/modules/kit/regions/repo/AGENTS.md @@ -0,0 +1,2 @@ +Read .kit/1st.md at the start of every session. It defines how any agent uses empowerite/kit to keep this +repository trustworthy. diff --git a/modules/lefthook/docs/README.md b/modules/lefthook/docs/README.md new file mode 100644 index 0000000..eb0aeaa --- /dev/null +++ b/modules/lefthook/docs/README.md @@ -0,0 +1,13 @@ +# The lefthook module + +Delivers `.kit/lefthook.yml`, a pre-commit hook with two commands over the staged paths: + +- `kit lint` runs each rule's own linter over the paths the rule governs, before the commit. +- `kit generate --check` refuses a stale generated region before it is committed. + +The repository's own `lefthook.yml` includes the hook with `extends: [.kit/lefthook.yml]` and adds its own +commands. Lefthook merges commands by name. The kit writes nothing into `lefthook.yml`. The module requires the +`extends` line: `kit install` lists it as a step left to do, and `kit check` fails until it is there. + +The repository pins `lefthook` itself, because only a developer's machine runs a hook. `kit lint` and +`kit generate` run the same way without any hook manager. diff --git a/modules/lefthook/files/repo/.kit/lefthook.yml b/modules/lefthook/files/repo/.kit/lefthook.yml new file mode 100644 index 0000000..f1121a3 --- /dev/null +++ b/modules/lefthook/files/repo/.kit/lefthook.yml @@ -0,0 +1,19 @@ +# Delivered by kit, not edited by hand. A change is a new release, then kit update. +# +# The pre-commit hook runs two commands over the staged files: +# +# - kit-lint runs kit lint: each rule's own linter, declared in the rule's frontmatter, over the files the rule +# governs +# - kit-generate runs kit generate --check, which fails when a generated region is stale +# +# The repository's lefthook.yml includes this file with `extends: [.kit/lefthook.yml]`, and can add commands of its +# own. Lefthook merges commands by name. Run `lefthook install` once per clone. +# +# see: https://lefthook.dev + +pre-commit: + commands: + kit-lint: + run: kit lint {staged_files} + kit-generate: + run: kit generate --check {staged_files} diff --git a/modules/lefthook/module.yml b/modules/lefthook/module.yml new file mode 100644 index 0000000..f2be1c1 --- /dev/null +++ b/modules/lefthook/module.yml @@ -0,0 +1,11 @@ +# modules/lefthook/module.yml - the lefthook module: a pre-commit hook for kit lint and kit generate --check +# ---------------------------------------------------------------------------------------------------------------------- +# see: docs/modules.md + +description: "A pre-commit hook that runs kit lint over the staged paths, for the repository's lefthook.yml to extend" +depends: + - kit +expects: + - path: lefthook.yml + pattern: '\.kit/lefthook\.yml' + remedy: "put extends: [.kit/lefthook.yml] in lefthook.yml, so the kit's pre-commit commands run" diff --git a/modules/module.schema.json b/modules/module.schema.json new file mode 100644 index 0000000..c3537d7 --- /dev/null +++ b/modules/module.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/empowerite/kit/modules/module.schema.json", + "title": "module.yml", + "description": "The metadata of one kit module, modules/<name>/module.yml. Each key is described in docs/modules.md.", + "type": "object", + "additionalProperties": false, + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "description": "One line, shown by kit module list." + }, + "default": { + "type": "boolean", + "description": "When true, the module is active unless removed. kit install activates every default module." + }, + "depends": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "The name of another module in the release, activated with this one." + } + }, + "answers": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z_]+$": { + "type": "string", + "description": "The default value of a template key, written @key@ in the module's templates." + } + } + }, + "expects": { + "type": "array", + "description": "Lines the module expects in files the repository owns, such as the line that includes a delivered file (extends: [.kit/lefthook.yml]). kit check reports each missing line with its remedy. The kit never writes these lines.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "pattern", + "remedy" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "The file, relative to the repository root." + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "An extended regular expression some line of the file must match." + }, + "remedy": { + "type": "string", + "minLength": 1, + "description": "What to add, and why, printed when no line matches." + } + } + } + } + } +} diff --git a/modules/policy-opa/docs/README.md b/modules/policy-opa/docs/README.md new file mode 100644 index 0000000..b54c026 --- /dev/null +++ b/modules/policy-opa/docs/README.md @@ -0,0 +1,11 @@ +# The policy-opa module + +Delivers `.kit/policy/kit.rego`, the kit's naming policy in Rego, in package `kit.policy`. When a `.rego` file is +present under `.kit/policy/`, `kit policy` evaluates the policy with Open Policy Agent instead of the default +patterns engine. + +The rules in `kit.rego` refuse exactly what the patterns engine refuses. Taking the module changes nothing until +the repository adds a rule of its own. To add one, put a `.rego` file of the repository's own beside `kit.rego`, +in the same package, that adds to `deny`. + +`kit policy --input` prints the input document a rule reads. The repository pins `opa` in its toolchain. diff --git a/modules/policy-opa/files/repo/.kit/policy/kit.rego b/modules/policy-opa/files/repo/.kit/policy/kit.rego new file mode 100644 index 0000000..4b706c1 --- /dev/null +++ b/modules/policy-opa/files/repo/.kit/policy/kit.rego @@ -0,0 +1,33 @@ +# kit.rego - the kit's naming policy in Rego: the same rules as the patterns engine +# ---------------------------------------------------------------------------------------------------------------------- +# Delivered by kit, not edited by hand. A change is a new release, then kit update. Put the repository's own rules +# in a separate file beside this one, in the same package, adding to deny. `kit policy --input` prints the input. + +package kit.policy + +import rego.v1 + +deny contains msg if { + input.branch != null + pattern := input.policy.branch + pattern != "" + not regex.match(pattern, input.branch) + msg := sprintf("branch '%s' does not match policy.branch: %s", [input.branch, pattern]) +} + +deny contains msg if { + pattern := input.policy.commit_subject + pattern != "" + some commit in input.commits + not regex.match(pattern, commit.subject) + msg := sprintf("commit %s '%s' does not match policy.commit_subject: %s", + [substring(commit.sha, 0, 7), commit.subject, pattern]) +} + +deny contains msg if { + input.change != null + pattern := input.policy.change_title + pattern != "" + not regex.match(pattern, input.change.title) + msg := sprintf("change title '%s' does not match policy.change_title: %s", [input.change.title, pattern]) +} diff --git a/modules/policy-opa/module.yml b/modules/policy-opa/module.yml new file mode 100644 index 0000000..a7e6d19 --- /dev/null +++ b/modules/policy-opa/module.yml @@ -0,0 +1,7 @@ +# modules/policy-opa/module.yml - the policy-opa module: the kit's naming policy as Rego, for Open Policy Agent +# ---------------------------------------------------------------------------------------------------------------------- +# see: docs/modules.md + +description: "The kit's naming policy as Rego, so kit policy runs on Open Policy Agent and rules can grow beyond patterns" +depends: + - kit diff --git a/tests/ci.bats b/tests/ci.bats new file mode 100644 index 0000000..2e28d8a --- /dev/null +++ b/tests/ci.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats +# ci.bats - kit ci runs the gate, the kit's check, and the issue link, and reports every one +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${BATS_TEST_TMPDIR}/shims" + printf '#!/usr/bin/env bash\nexit 1\n' > "${BATS_TEST_TMPDIR}/shims/gh" && chmod +x "${BATS_TEST_TMPDIR}/shims/gh" + export PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + git remote add origin https://github.com/empowerite/example.git + export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/summary.md" + "${kit_checkout}/bin/kit" install >/dev/null +} + +teardown() { + rm -rf "${repo}" +} + +@test "every check runs and the refusals are counted" { + printf 'ci:\n echo gate ran\n' > justfile + run env -u GITHUB_EVENT_PATH "${kit_checkout}/bin/kit" ci + [ "$status" -eq 1 ] + [[ "$output" == *"gate ran"* ]] + [[ "$output" == *"every delivered file matches"* ]] + [[ "$output" == *"kit linked <number>"* ]] + [[ "$output" == *"the patterns policy allows"* ]] + [[ "$output" == *"1 of 4 checks refused"* ]] + printf 'edited\n' >> .kit/1st.md + rm justfile + run env -u GITHUB_EVENT_PATH "${kit_checkout}/bin/kit" ci + [ "$status" -eq 3 ] + [[ "$output" == *"3 of 4 checks refused"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/context.bats b/tests/context.bats new file mode 100644 index 0000000..3643081 --- /dev/null +++ b/tests/context.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# context.bats - kit context names a file's rules by path or by first line, logs the ask, and answers Claude's hook +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + repo=$(mktemp -d) + git -C "${repo}" init --quiet --initial-branch=main + cd "${repo}" + "${kit_checkout}/bin/kit" install >/dev/null +} + +teardown() { + rm -rf "${repo}" +} + +@test "a file is governed by its path, by its first line, or by nothing" { + # a real file the rule's glob would expand to, were the glob ever word-split + mkdir docs && printf '# x\n' > docs/x.md + run "${kit_checkout}/bin/kit" context README.md + [ "$output" = ".kit/rules/markdown.md" ] + run "${kit_checkout}/bin/kit" context justfile + [ "$status" -eq 0 ] + [ "$output" = ".kit/rules/just.md" ] + mkdir bin && printf '#!/usr/bin/env bash\necho\n' > bin/tool + run "${kit_checkout}/bin/kit" context bin/tool + [ "$output" = ".kit/rules/bash.md" ] + run "${kit_checkout}/bin/kit" context bin/other + [ "$status" -eq 0 ] + [ -z "$output" ] + run "${kit_checkout}/bin/kit" context docs/a.md "${PWD}/.github/workflows/x.yml" lib/x.md + [ "${lines[0]}" = ".kit/rules/markdown.md" ] + [ "${lines[1]}" = ".kit/rules/workflows.md" ] + [ "${lines[2]}" = ".kit/rules/yaml.md" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "a relative path is taken from the directory the command was typed in" { + mkdir docs && cd docs + run "${kit_checkout}/bin/kit" context x.md + [ "$output" = ".kit/rules/markdown.md" ] + [ "$(cut -f 2 ../.git/kit/context.log)" = "docs/x.md" ] +} + +@test "the json answer is one object per path, and every ask is logged" { + run "${kit_checkout}/bin/kit" context --json justfile README.md + [ "$status" -eq 0 ] + [ "$(jq -c .rules <<<"${lines[0]}")" = '[".kit/rules/just.md"]' ] + [ "$(jq -r .path <<<"${lines[1]}")" = "README.md" ] + [ "$(cut -f 2 .git/kit/context.log | paste -s -d ' ' -)" = "justfile README.md" ] +} + +@test "the claude hook names the rules of the file about to be written, and nothing for a file none governs" { + input=$(printf '{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"%s/lib/run.sh"}}' "${PWD}") + run "${kit_checkout}/bin/kit" context --claude <<<"${input}" + [ "$status" -eq 0 ] + [ "$(jq -r .hookSpecificOutput.hookEventName <<<"$output")" = "PreToolUse" ] + [ "$(jq -r .hookSpecificOutput.additionalContext <<<"$output")" \ + = "kit: lib/run.sh is governed by .kit/rules/bash.md; read it before this change." ] + [ "$(cut -f 2 .git/kit/context.log)" = "lib/run.sh" ] + input='{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"x.bin"}}' + run "${kit_checkout}/bin/kit" context --claude <<<"${input}" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/fakes/fake-tool b/tests/fakes/fake-tool new file mode 100755 index 0000000..1ca6afc --- /dev/null +++ b/tests/fakes/fake-tool @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# fake-tool - stands in for a forge's command-line tool in the conformance suite (test helper) +# ---------------------------------------------------------------------------------------------------------------------- +# +# Linked as gh or glab on PATH. Every call is appended to ${FAKE_DIR}/<tool>.calls. The response is the first line +# of ${FAKE_DIR}/<tool>.routes whose glob matches the arguments joined by spaces: "<glob>\t<file>", the file's +# contents printed, or "<glob>\t!<status>" to fail with that status. A gh call carrying --jq has the filter applied +# to the response, as gh does, so the provider's own filter is what is tested. An unrouted call fails. + +set -o errexit -o nounset -o pipefail +tool=${0##*/} +printf '%s\n' "$*" >> "${FAKE_DIR}/${tool}.calls" +filter='' +args=("$@") +for (( i = 0; i < ${#args[@]}; i++ )) +do + [[ "${args[i]}" == --jq ]] && filter=${args[i + 1]} +done +while IFS=$'\t' read -r glob response +do + # shellcheck disable=SC2053 # the route is a glob on purpose + [[ -n "${glob}" && "$*" == ${glob} ]] || continue + if [[ "${response}" == '!'* ]] + then + exit "${response#!}" + fi + if [[ -n "${filter}" ]] + then + jq --raw-output "${filter}" "${FAKE_DIR}/${response}" + else + cat "${FAKE_DIR}/${response}" + fi + exit 0 +done < "${FAKE_DIR}/${tool}.routes" +echo "${tool}: unrouted call: $*" >&2 +exit 1 + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/forge-conformance.bats b/tests/forge-conformance.bats new file mode 100644 index 0000000..aafc710 --- /dev/null +++ b/tests/forge-conformance.bats @@ -0,0 +1,243 @@ +#!/usr/bin/env bats +# forge-conformance.bats - every forge provider answers the seam's calls in the same shape, through a fake of its tool +# ---------------------------------------------------------------------------------------------------------------------- +# +# One world, seen through each forge's own API: the repository empowerite/example, the caller ada, issue 7 "Fix +# the thing" assigned to bob, and change 3 on branch 7-fix-the-thing, opened by ada, closing issue 7, with two +# checks that passed in a minute and one unresolved review thread. Each provider is driven by tests/fakes/fake-tool +# standing in for its tool, so what is proven is the provider's own folding of its forge's answers into the seam's +# shape. What only a real forge can prove, that the API answers as the fixtures do, is not proven here. + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + export FAKE_DIR="${BATS_TEST_TMPDIR}/fake" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${FAKE_DIR}/bin" + ln -s "${BATS_TEST_DIRNAME}/fakes/fake-tool" "${FAKE_DIR}/bin/gh" + ln -s "${BATS_TEST_DIRNAME}/fakes/fake-tool" "${FAKE_DIR}/bin/glab" + export PATH="${FAKE_DIR}/bin:${PATH}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + unset GITHUB_EVENT_PATH CI_MERGE_REQUEST_IID +} + +teardown() { + rm -rf "${repo}" +} + +# call <call> <argument>… runs one call of the seam, as a verb would. +call() { + bash -c 'source "${kit_root}/bin/1st.sh" && use forge && "forge:$0" "$@"' "$@" +} + +# provider prints the provider the seam picks here. +provider() { + bash -c 'source "${kit_root}/bin/1st.sh" && use forge && forge:provider' +} + +# fixture <name> writes stdin to a response file the routes name. +fixture() { cat > "${FAKE_DIR}/$1"; } + +# route <tool> <glob> <response> adds a route, the more particular before the more general. +route() { printf '%s\t%s\n' "$2" "$3" >> "${FAKE_DIR}/$1.routes"; } + +# world <forge> makes this repository's origin the forge's, and its tool's routes the world above. +world() { + git remote remove origin 2>/dev/null || true + "world-$1" +} + +world-github() { + git remote add origin https://github.com/empowerite/example.git + : > "${FAKE_DIR}/gh.routes" + fixture user.json <<<'{"login": "ada"}' + fixture repo.json <<<'{"nameWithOwner": "empowerite/example", "url": "https://github.com/empowerite/example"}' + fixture issue.json <<<'{"title": "Fix the thing", "state": "OPEN", "assignees": [{"login": "bob"}]}' + fixture open-heads.json <<<'[{"headRefName": "9-other"}, {"headRefName": "7-fix-the-thing"}]' + fixture pr-open.json <<<'[{"number": 3}]' + fixture pr-all.json <<<'[{"state": "OPEN"}]' + fixture pr-url.txt <<<'https://github.com/empowerite/example/pull/3' + fixture issue-url.txt <<<'https://github.com/empowerite/example/issues/7' + fixture pr.json <<<'{"title": "Fix the thing", "body": "Closes #7"}' + fixture closing.json <<'JSON' +{"data": {"repository": {"pullRequest": {"closingIssuesReferences": {"nodes": [ + {"number": 7, "state": "OPEN", "repository": {"nameWithOwner": "empowerite/example"}}]}}}}} +JSON + fixture open-prs.json <<'JSON' +{"data": {"repository": {"pullRequests": {"nodes": [{ + "number": 3, "title": "Fix the thing", "isDraft": false, "headRefName": "7-fix-the-thing", + "createdAt": "2026-10-01T09:00:00Z", "mergeStateStatus": "CLEAN", "reviewDecision": null, + "author": {"login": "ada"}, + "closingIssuesReferences": {"nodes": [{"number": 7, "title": "Fix the thing"}]}, + "reviewThreads": {"nodes": [{"isResolved": true}, {"isResolved": false}]}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": [ + {"__typename": "CheckRun", "name": "a", "status": "COMPLETED", "conclusion": "SUCCESS", + "startedAt": "2026-10-01T10:00:00Z", "completedAt": "2026-10-01T10:00:30Z"}, + {"__typename": "CheckRun", "name": "b", "status": "COMPLETED", "conclusion": "SUCCESS", + "startedAt": "2026-10-01T10:00:10Z", "completedAt": "2026-10-01T10:01:00Z"}]}}}}]}}]}}}} +JSON + route gh 'api user*' user.json + route gh 'repo view*' repo.json + route gh 'issue view 7 *' issue.json + route gh 'issue create*' issue-url.txt + route gh 'issue edit 7 --add-assignee ada' '!0' + route gh 'pr list --state open --json headRefName*' open-heads.json + route gh 'pr list --head 7-fix-the-thing --state open*' pr-open.json + route gh 'pr list --head 7-fix-the-thing --state all*' pr-all.json + route gh 'pr create --draft --base main --head 7-fix-the-thing --title Fix the thing*' pr-url.txt + route gh 'pr view 3 --json *' pr.json + route gh 'api graphql*number=3*' closing.json + route gh 'api graphql*' open-prs.json +} + +world-gitlab() { + git remote add origin git@gitlab.com:empowerite/example.git + : > "${FAKE_DIR}/glab.routes" + local p='projects/empowerite%2Fexample' + fixture user.json <<<'{"username": "ada"}' + fixture issue.json <<<'{"iid": 7, "title": "Fix the thing", "state": "opened", "assignees": [{"username": "bob"}]}' + fixture mrs-open.json <<<'[{"iid": 2, "source_branch": "9-other"}, {"iid": 3, "source_branch": "7-fix-the-thing"}]' + fixture mrs-list.json <<<'[{"iid": 3}]' + fixture mr-by-branch.json <<<'[{"iid": 3}]' + fixture mr-states.json <<<'[{"state": "opened"}]' + fixture mr-url.txt <<<'https://gitlab.com/empowerite/example/-/merge_requests/3' + fixture issue-url.txt <<<'https://gitlab.com/empowerite/example/-/issues/7' + fixture mr.json <<'JSON' +{"iid": 3, "title": "Fix the thing", "draft": false, "source_branch": "7-fix-the-thing", + "created_at": "2026-10-01T09:00:00.000Z", "author": {"username": "ada"}, "has_conflicts": false, + "detailed_merge_status": "mergeable", "description": "Closes #7", "head_pipeline": {"id": 11, "status": "success"}} +JSON + fixture jobs.json <<'JSON' +[{"name": "a", "status": "success", "started_at": "2026-10-01T10:00:00.120Z", "finished_at": "2026-10-01T10:00:30.000Z"}, + {"name": "b", "status": "success", "started_at": "2026-10-01T10:00:10.000Z", "finished_at": "2026-10-01T10:01:00.450Z"}] +JSON + fixture discussions.json <<'JSON' +[{"notes": [{"resolvable": true, "resolved": true}]}, {"notes": [{"resolvable": true, "resolved": false}]}, + {"notes": [{"resolvable": false, "resolved": false}]}] +JSON + fixture closes.json <<'JSON' +[{"iid": 7, "title": "Fix the thing", "state": "opened", "references": {"full": "empowerite/example#7"}}] +JSON + fixture none.json <<<'[]' + route glab 'api user' user.json + route glab "api ${p}/issues/7" issue.json + route glab 'issue create*' issue-url.txt + route glab 'issue update 7 --assignee +ada' '!0' + route glab "api ${p}/merge_requests?state=opened&per_page=100&order_by=*" mrs-list.json + route glab "api ${p}/merge_requests?state=opened&per_page=100" mrs-open.json + route glab "api ${p}/merge_requests?state=opened&source_branch=7-fix-the-thing" mr-by-branch.json + route glab "api ${p}/merge_requests?state=all&source_branch=7-fix-the-thing*" mr-states.json + route glab "api ${p}/merge_requests/3/closes_issues" "${GITLAB_CLOSES:-closes.json}" + route glab "api ${p}/merge_requests/3/discussions*" discussions.json + route glab "api ${p}/merge_requests/3" mr.json + route glab "api ${p}/pipelines/11/jobs*" jobs.json + route glab "api ${p}/repository/branches --method POST*" '!0' + route glab 'mr create --draft --source-branch 7-fix-the-thing --target-branch main --title Fix the thing*' mr-url.txt +} + +@test "the forge is picked by origin's host, github.com or gitlab.com, and either answers the same plain calls" { + for forge in github gitlab + do + world "${forge}" + run provider + [ "$output" = "${forge}" ] + run call repo + [ "$output" = "empowerite/example" ] + run call me + [ "$output" = "ada" ] + run call issue-title 7 + [ "$output" = "Fix the thing" ] + run call issue-state 7 + [ "$output" = "OPEN" ] + run call issue-assignees 7 + [ "$output" = "bob" ] + run call issue-create "Fix the thing" + [ "$output" = "7" ] + run call issue-assign 7 ada + [ "$status" -eq 0 ] + done +} + +@test "every provider finds, opens, and reads a change by its branch the same way" { + for forge in github gitlab + do + world "${forge}" + run call branch-for-issue 7 + [ "$output" = "7-fix-the-thing" ] + run call branch-for-issue 8 + [ -z "$output" ] + run call pr-for-branch 7-fix-the-thing + [ "$output" = "3" ] + run call pr-state-for-branch 7-fix-the-thing + [ "$output" = "OPEN" ] + run call pr-create 7-fix-the-thing main "Fix the thing" + [ "$output" = "3" ] + run call pr-closing-issues 3 + [ "$output" = "7 OPEN empowerite/example" ] + run call pr-title 3 + [ "$output" = "Fix the thing" ] + run call pr-body 3 + [ "$output" = "Closes #7" ] + done +} + +@test "every provider folds its open changes into the one document kit status draws" { + expected='{"number":3,"branch":"7-fix-the-thing","title":"Fix the thing","author":"ada",' + expected+='"state":"comments:open","checks":{"state":"SUCCESS","passed":2,"total":2,"failing":"","took":60},' + expected+='"unresolved":1,"closes":[{"number":7,"title":"Fix the thing"}]}' + for forge in github gitlab + do + world "${forge}" + run call open-prs + [ "$status" -eq 0 ] + [ "$(jq --compact-output --sort-keys 'del(.age, .createdAt, .ref)' <<<"$output")" \ + = "$(jq --compact-output --sort-keys . <<<"${expected}")" ] + done +} + +@test "each provider names the change in its own words, and the rest of the picture is identical" { + world github + run call noun + [ "$output" = "$(printf '#\tpull request')" ] + run call pr-url 3 + [ "$output" = "https://github.com/empowerite/example/pull/3" ] + [ "$(call open-prs | jq --raw-output .ref)" = "#3" ] + world gitlab + run call noun + [ "$output" = "$(printf '!\tmerge request')" ] + run call pr-url 3 + [ "$output" = "https://gitlab.com/empowerite/example/-/merge_requests/3" ] + [ "$(call open-prs | jq --raw-output .ref)" = "!3" ] +} + +@test "GitLab makes the branch for an issue as its issue page would, and a self-hosted GitLab is the setting" { + world gitlab + run call link-branch 7 main + [ "$status" -eq 0 ] + grep --quiet -- '--raw-field branch=7-fix-the-thing --raw-field ref=main' "${FAKE_DIR}/glab.calls" + git remote set-url origin https://git.example.com/empowerite/example.git + run provider + [ "$status" -eq 1 ] + mkdir .kit && printf 'forge: gitlab\n' > .kit/settings.yml + run provider + [ "$output" = "gitlab" ] + run call repo-url + [ "$output" = "https://git.example.com/empowerite/example" ] +} + +@test "kit linked speaks the forge's words: a merge request on GitLab, its URL in the remedy" { + export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/summary.md" + GITLAB_CLOSES=none.json world gitlab + run "${kit_root}/bin/kit" linked 3 + [ "$status" -eq 1 ] + [[ "$output" == *"merge request !3 is not linked to any issue"* ]] + grep --quiet 'https://gitlab.com/empowerite/example/-/merge_requests/3' "${GITHUB_STEP_SUMMARY}" + world gitlab + run "${kit_root}/bin/kit" linked 3 + [ "$status" -eq 0 ] + [[ "$output" == *"merge request !3 is linked to #7"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/forge.bats b/tests/forge.bats new file mode 100644 index 0000000..5e9e467 --- /dev/null +++ b/tests/forge.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats +# forge.bats - the forge is the setting, else the one origin's host names, and an unknown one is refused by name +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main +} + +teardown() { + rm -rf "${repo}" +} + +# provider prints the provider the seam picks here. +provider() { + bash -c 'source "${kit_root}/bin/1st.sh" && use forge && forge:provider' +} + +@test "an origin at github.com names the github provider, and one at gitlab.com the gitlab one, by https or ssh" { + git remote add origin https://github.com/empowerite/kit.git + run provider + [ "$status" -eq 0 ] + [ "$output" = "github" ] + git remote set-url origin git@github.com:empowerite/kit.git + run provider + [ "$output" = "github" ] + git remote set-url origin git@gitlab.com:empowerite/kit.git + run provider + [ "$output" = "gitlab" ] +} + +@test "an origin elsewhere, or none, is refused naming the setting and the forges the kit knows" { + git remote add origin https://codeberg.org/empowerite/kit.git + run provider + [ "$status" -eq 1 ] + [[ "$output" == *"set forge in .kit/settings.yml; origin's host 'codeberg.org' names no forge the kit knows"* ]] + [[ "$output" == *"(github gitlab)"* ]] + git remote remove origin + run provider + [ "$status" -eq 1 ] + [[ "$output" == *"origin's host 'none'"* ]] +} + +@test "the forge setting wins, and an unknown one is refused by name" { + git remote add origin https://codeberg.org/empowerite/kit.git + mkdir .kit && printf 'forge: github\n' > .kit/settings.yml + run provider + [ "$status" -eq 0 ] + [ "$output" = "github" ] + printf 'forge: sourcehut\n' > .kit/settings.yml + run provider + [ "$status" -eq 1 ] + [[ "$output" == *"'sourcehut' is not a forge the kit knows; the forges are: github gitlab"* ]] +} + +@test "the core verbs need no forge and no gh" { + mkdir -p "${BATS_TEST_TMPDIR}/shims" && printf '#!/usr/bin/env bash\nexit 127\n' > "${BATS_TEST_TMPDIR}/shims/gh" + chmod +x "${BATS_TEST_TMPDIR}/shims/gh" + run env PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" "${kit_root}/bin/kit" install claude + [ "$status" -eq 0 ] + run env PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" "${kit_root}/bin/kit" check + [ "$status" -eq 0 ] + run env PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" "${kit_root}/bin/kit" context justfile + [ "$status" -eq 0 ] + run env PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" "${kit_root}/bin/kit" status --no-fetch + [[ "$output" == *"set forge in .kit/settings.yml"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/gate.bats b/tests/gate.bats new file mode 100644 index 0000000..22b5484 --- /dev/null +++ b/tests/gate.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# gate.bats - kit gate runs the root justfile's ci recipe, and refuses a missing one and an unreadable one alike +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + repo=$(mktemp -d) + cd "${repo}" + export GITHUB_STEP_SUMMARY="${repo}/summary.md" +} + +teardown() { + rm -rf "${repo}" +} + +@test "no gate is refused, naming the remedy" { + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 1 ] + [[ "$output" == *"set gate in .kit/settings.yml, or give the root justfile a ci recipe"* ]] + grep --quiet 'cannot run' summary.md +} + +@test "a justfile without a ci recipe is no gate" { + printf 'build:\n echo built\n' > justfile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 1 ] + [[ "$output" == *"there is no gate to run"* ]] +} + +@test "the gate setting wins, else a Makefile or a package.json is detected" { + mkdir .kit && printf 'gate: echo from the setting\n' > .kit/settings.yml + printf 'ci:\n echo from just\n' > justfile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 0 ] + [[ "$output" == *"from the setting"* ]] + rm -r .kit justfile + printf 'ci:\n\t@echo from make\n' > Makefile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 0 ] + [[ "$output" == *"from make"* ]] + rm Makefile + printf '{"scripts": {"ci": "echo from npm"}}\n' > package.json + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 0 ] + [[ "$output" == *"the gate: npm run ci"* ]] +} + +@test "a justfile that cannot be read is a failure" { + printf 'ci:\n echo\nci:\n echo twice\n' > justfile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 1 ] + [[ "$output" == *"the root justfile could not be read"* ]] + grep --quiet 'could not be read' summary.md +} + +@test "a ci recipe runs, and its failure is the gate's" { + printf 'ci:\n echo gate ran\n' > justfile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 0 ] + [[ "$output" == *"gate ran"* ]] + grep --quiet 'gate passed' summary.md + printf 'ci:\n exit 3\n' > justfile + run "${kit_checkout}/bin/kit" gate + [ "$status" -eq 1 ] + [[ "$output" == *"the gate failed: just ci"* ]] + grep --quiet 'gate failed' summary.md +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/generate.bats b/tests/generate.bats new file mode 100644 index 0000000..6123edd --- /dev/null +++ b/tests/generate.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats +# generate.bats - a kit:generate directive opens a named region its command fills, nested and balanced, held by --check +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + work=$(mktemp -d) + cd "${work}" + git init --quiet --initial-branch=main + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + # A generator in the tree: a script that prints its arguments, one per line, so a region's content is visible. + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$@"\n' > lines.sh && chmod +x lines.sh +} + +teardown() { + rm -rf "${work}" +} + +# joined <file> prints the file's lines joined by |, so a whole file is one assertion. +joined() { paste -s -d '|' - < "$1"; } + +@test "a region is named, fills up to its end marker, and is read whatever comment syntax frames it" { + printf 'before\n<!-- kit:generate a `./lines.sh one two` -->\nstale\n<!-- kit:end a -->\nafter\n' > a.md + printf '# kit:generate b `./lines.sh x=1`\nold=0\n# kit:end b\nkeep this\n' > b.toml + printf '; kit:generate c `./lines.sh z`\n; kit:end c\n' > c.ini + printf "' kit:generate d \`./lines.sh v\`\n' kit:end d\n" > d.vb + run "${kit_checkout}/bin/kit" generate a.md b.toml c.ini d.vb + [ "$status" -eq 0 ] + [[ "$output" == *"a.md: regenerated"* ]] + [ "$(joined a.md)" = 'before|<!-- kit:generate a `./lines.sh one two` -->|one|two|<!-- kit:end a -->|after' ] + [ "$(joined b.toml)" = '# kit:generate b `./lines.sh x=1`|x=1|# kit:end b|keep this' ] + [ "$(joined c.ini)" = '; kit:generate c `./lines.sh z`|z|; kit:end c' ] + [ "$(joined d.vb)" = "' kit:generate d \`./lines.sh v\`|v|' kit:end d" ] + run "${kit_checkout}/bin/kit" generate a.md b.toml c.ini d.vb + [ "$status" -eq 0 ] + [[ "$output" == *"settled in 1 pass"* ]] +} + +@test "a command is a bash command line: a pipe works, a failure holds the file, and the file and region are in its environment" { + printf '# kit:generate sorted `./lines.sh c b a | sort`\n# kit:end sorted\n' > sorted.txt + printf '# kit:generate env `printf "%%s %%s\\n" "$KIT_GENERATE_FILE" "$KIT_GENERATE_REGION"`\n# kit:end env\n' > env.txt + run "${kit_checkout}/bin/kit" generate sorted.txt env.txt + [ "$status" -eq 0 ] + [ "$(joined sorted.txt)" = '# kit:generate sorted `./lines.sh c b a | sort`|a|b|c|# kit:end sorted' ] + [ "$(sed -n '2p' env.txt)" = 'env.txt env' ] + printf '# kit:generate f `./lines.sh a | grep --quiet zzz`\nheld\n# kit:end f\n' > fail.txt + run "${kit_checkout}/bin/kit" generate fail.txt + [ "$status" -eq 1 ] + [[ "$output" == *"fail.txt:1: the command failed: ./lines.sh a | grep --quiet zzz"* ]] + grep --quiet '^held$' fail.txt + printf '# kit:generate m `./missing.sh`\nheld\n# kit:end m\n' > missing.txt + run "${kit_checkout}/bin/kit" generate missing.txt + [ "$status" -eq 1 ] + [[ "$output" == *"missing.txt:1: the command failed: ./missing.sh"* ]] + grep --quiet '^held$' missing.txt +} + +@test "a region nests when its command's output opens one, and an output must be balanced on its own" { + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "head" "# kit:generate inner \\`./lines.sh deep\\`" "# kit:end inner" "tail"\n' > nest.sh + chmod +x nest.sh + printf '# kit:generate outer `./nest.sh`\n# kit:end outer\n' > n.txt + run "${kit_checkout}/bin/kit" generate n.txt + [ "$status" -eq 0 ] + [ "$(joined n.txt)" = '# kit:generate outer `./nest.sh`|head|# kit:generate inner `./lines.sh deep`|deep|# kit:end inner|tail|# kit:end outer' ] + run "${kit_checkout}/bin/kit" generate --check + [ "$status" -eq 0 ] + [[ "$output" == *"every region as its command makes it"* ]] + printf '# kit:generate outer `./lines.sh "# kit:end outer"`\nheld\n# kit:end outer\n' > close.txt + run "${kit_checkout}/bin/kit" generate close.txt + [ "$status" -eq 1 ] + [[ "$output" == *"close.txt:1 (the output of ./lines.sh \"# kit:end outer\"):1: closes 'outer', which is not open"* ]] + grep --quiet '^held$' close.txt + printf '# kit:generate outer `./lines.sh "# kit:generate loose \\`./lines.sh x\\`"`\nheld\n# kit:end outer\n' > open.txt + run "${kit_checkout}/bin/kit" generate open.txt + [ "$status" -eq 1 ] + [[ "$output" == *"open.txt:1 (the output of"*"):1: the region 'loose' never closes"* ]] + grep --quiet '^held$' open.txt +} + +@test "an unbalanced, malformed, or repeated marker holds the file and names the line, as a conflict marker does" { + printf 'top\n# kit:end z\n' > stray.txt + run "${kit_checkout}/bin/kit" generate stray.txt + [ "$status" -eq 1 ] + [[ "$output" == *"stray.txt:2: closes 'z', which is not open"* ]] + printf '# kit:generate b `./lines.sh x`\n# kit:end a\n# kit:end b\n' > cross.txt + run "${kit_checkout}/bin/kit" generate cross.txt + [ "$status" -eq 1 ] + [[ "$output" == *"cross.txt:2: closes 'a' while 'b' is open"* ]] + printf 'top\n# kit:generate a `./lines.sh x`\nheld\n' > never.txt + run "${kit_checkout}/bin/kit" generate never.txt + [ "$status" -eq 1 ] + [[ "$output" == *"never.txt:2: the region 'a' never closes"* ]] + grep --quiet '^held$' never.txt + printf '# kit:generate\n# kit:end\n' > noname.txt + run "${kit_checkout}/bin/kit" generate noname.txt + [ "$status" -eq 1 ] + [[ "$output" == *"noname.txt:1: not a directive: # kit:generate"* ]] + printf '# kit:generate a `./lines.sh x`\n# kit:end a\n# kit:generate a `./lines.sh y`\n# kit:end a\n' > twice.txt + run "${kit_checkout}/bin/kit" generate twice.txt + [ "$status" -eq 1 ] + [[ "$output" == *"twice.txt: the region 'a' is opened twice, the second time at line 4 of the expanded file"* ]] + printf '<<<<<<< ours\n# kit:generate a `./lines.sh x`\n# kit:end a\n>>>>>>> theirs\n' > conflict.txt + run "${kit_checkout}/bin/kit" generate conflict.txt + [ "$status" -eq 1 ] + [[ "$output" == *"conflict.txt carries an unresolved conflict"* ]] + printf 'x\n' > plain.txt + run "${kit_checkout}/bin/kit" generate plain.txt + [ "$status" -eq 0 ] + [[ "$output" == *"no kit:generate directive to expand"* ]] +} + +@test "a line is a directive by what frames it: prose is not, a bare marker line is an error, and a markdown fence is not live" { + printf 'A kit:generate x line opens a region, and kit:end x closes it.\n' > prose.md + run "${kit_checkout}/bin/kit" generate --check prose.md + [ "$status" -eq 0 ] + [[ "$output" == *"no kit:generate directive"* ]] + printf -- '- kit:generate x is how a region opens\n' > bullet.md + run "${kit_checkout}/bin/kit" generate bullet.md + [ "$status" -eq 1 ] + [[ "$output" == *"bullet.md:1: not a directive: - kit:generate x is how a region opens"* ]] + printf '```markdown\n<!-- kit:generate shown `./lines.sh shown` -->\n<!-- kit:end shown -->\n```\nprose\n' > doc.md + run "${kit_checkout}/bin/kit" generate doc.md + [ "$status" -eq 0 ] + run grep --quiet '^shown$' doc.md + [ "$status" -eq 1 ] + printf 'cat <<EOF\n```\nEOF\n# kit:generate s `./lines.sh live`\n# kit:end s\n' > fenced.sh + run "${kit_checkout}/bin/kit" generate fenced.sh + [ "$status" -eq 0 ] + grep --quiet '^live$' fenced.sh +} + +@test "the whole repository is discovered, tracked or not, --check names a stale file without writing it, and a symlink lands in its target" { + printf '<!-- kit:generate a `./lines.sh fresh` -->\nstale\n<!-- kit:end a -->\n' > a.md + mkdir sub && printf '# kit:generate b `./lines.sh deep`\n# kit:end b\n' > sub/b.yml + printf 'no directive here\n' > c.md + git add a.md && printf 'ignored/\n' > .gitignore && mkdir ignored && cp a.md ignored/d.md + run "${kit_checkout}/bin/kit" generate --check + [ "$status" -eq 1 ] + [[ "$output" == *"a.md: stale; run: kit generate"* ]] + [[ "$output" == *"sub/b.yml: stale"* ]] + [[ "$output" != *"ignored/d.md"* ]] + [[ "$output" == *"2 of 2 generated file(s) are stale"* ]] + grep --quiet '^stale$' a.md + cd sub + run "${kit_checkout}/bin/kit" generate + [ "$status" -eq 0 ] + [[ "$output" == *"settled in 2 pass(es)"* ]] + [ "$(joined b.yml)" = '# kit:generate b `./lines.sh deep`|deep|# kit:end b' ] + run "${kit_checkout}/bin/kit" generate --check b.yml ../c.md + [ "$status" -eq 0 ] + cd .. + mkdir real && printf '# kit:generate l `./lines.sh linked`\n# kit:end l\n' > real/x.sh && ln -s real/x.sh x.sh + run "${kit_checkout}/bin/kit" generate x.sh + [ "$status" -eq 0 ] + [ -L x.sh ] + grep --quiet '^linked$' real/x.sh +} + +@test "a bare directive means kit region, which reads the name, so the kit's regions carry no command and need only kit on PATH" { + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ "$(sed -n '1p' AGENTS.md)" = '<!-- kit:generate AGENTS.md -->' ] + printf '## Ours\n' >> AGENTS.md + run env PATH="$(dirname "$(command -v bash)"):/usr/bin:/bin" "${kit_checkout}/bin/kit" generate --check + [ "$status" -eq 0 ] + [[ "$output" == *"every region as its command makes it"* ]] + printf '# kit:generate nothing\n# kit:end nothing\n' > bare.txt + run "${kit_checkout}/bin/kit" generate bare.txt + [ "$status" -eq 1 ] + [[ "$output" == *"no active module delivers a region at nothing"* ]] + [[ "$output" == *"bare.txt:1: the command failed: kit region"* ]] +} + +@test "a nested command's failure names the output's line, and an output that leaves a markdown fence open is refused" { + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "head" "# kit:generate inner \\`false\\`" "# kit:end inner"\n' > nf.sh && chmod +x nf.sh + printf '# kit:generate outer `./nf.sh`\nheld\n# kit:end outer\n' > nf.txt + run "${kit_checkout}/bin/kit" generate nf.txt + [ "$status" -eq 1 ] + [[ "$output" == *"nf.txt:1 (the output of ./nf.sh):2: the command failed: false"* ]] + grep --quiet '^held$' nf.txt + printf '#!/usr/bin/env bash\nprintf "%%s\\n" text %s more\n' "'\`\`\`'" > fence.sh && chmod +x fence.sh + printf '<!-- kit:generate odd `./fence.sh` -->\nheld\n<!-- kit:end odd -->\n' > odd.md + run "${kit_checkout}/bin/kit" generate odd.md + [ "$status" -eq 1 ] + [[ "$output" == *"odd.md:1 (the output of ./fence.sh): a fenced code block opens at line 2 and never closes"* ]] + grep --quiet '^held$' odd.md + printf '# Title\n\n```\n<!-- kit:generate a `./lines.sh x` -->\n<!-- kit:end a -->\n' > open.md + run "${kit_checkout}/bin/kit" generate open.md + [ "$status" -eq 1 ] + [[ "$output" == *"open.md: a fenced code block opens at line 3 and never closes"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/kit.bats b/tests/kit.bats new file mode 100644 index 0000000..b2f5624 --- /dev/null +++ b/tests/kit.bats @@ -0,0 +1,596 @@ +#!/usr/bin/env bats +# kit.bats - bin/kit install, module, check, and update against a fresh repository +# ---------------------------------------------------------------------------------------------------------------------- +# +# Each test gets an empty git repository under a temporary directory and runs the kit from this checkout against +# it. The update tests build a second, later release from the first so the three-way merge has a base. + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + repo=$(mktemp -d) + git -C "${repo}" init --quiet --initial-branch=main + # a home directory of the test's own, so no test ever writes under the real one + export KIT_HOME="${repo}.home" XDG_CACHE_HOME="${repo}.cache" + mkdir "${KIT_HOME}" "${XDG_CACHE_HOME}" + cd "${repo}" +} + +teardown() { + rm -rf "${repo}" "${repo}.later" "${repo}.home" "${repo}.cache" "${repo}.shims" +} + +# shims <tool>… puts a failing stand-in for each tool first on PATH, so a test proves a path is not taken. +shims() { + local tool + mkdir -p "${repo}.shims" + for tool in "$@" + do + printf '#!/usr/bin/env bash\nexit 1\n' > "${repo}.shims/${tool}" && chmod +x "${repo}.shims/${tool}" + done + printf '%s:%s\n' "${repo}.shims" "${PATH}" +} + +# later-release <dir> copies this checkout's release to <dir> as version 9.9.9 with one changed rule line. +later-release() { + mkdir -p "$1" + cp -R "${kit_checkout}/bin" "${kit_checkout}/modules" "$1/" + printf '9.9.9\n' > "$1/version" + printf '\n## Added in 9.9.9\n\nA new rule.\n' >> "$1/modules/kit/files/repo/.kit/rules/json.md" +} + +@test "install with no module writes the kit module and nothing else" { + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ -f .kit/1st.md ] + [ -f .kit/rules/bash.md ] + [ ! -e .kit/lefthook.yml ] + [ ! -e .github ] + [ ! -e .claude ] + grep --quiet '^version unreleased$' .kit/manifest + grep --quiet '^modules kit$' .kit/manifest + run "${kit_checkout}/bin/kit" install ci-github + [ "$status" -eq 1 ] + run "${kit_checkout}/bin/kit" module add ci-github + [ "$status" -eq 0 ] + grep --quiet '^ runs-on: "ubuntu-latest"$' .github/workflows/kit-ci.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + # a stray file under a module not active is nobody's business + mkdir -p .claude && printf '{"model":"x"}\n' > .claude/settings.json + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "install with claude adds the deny list and the context hook, and copies no rule" { + run "${kit_checkout}/bin/kit" install claude + [ "$status" -eq 0 ] + grep --quiet '^modules kit claude$' .kit/manifest + jq --exit-status '.permissions.deny | index("Bash(git rebase *)")' .claude/settings.json >/dev/null + jq --exit-status '.hooks.PreToolUse[0].hooks[0].command == "kit context --claude"' .claude/settings.json >/dev/null + grep --quiet --line-regexp '@../AGENTS.md' .claude/CLAUDE.md + grep --quiet --line-regexp '<!-- kit:generate .claude/CLAUDE.md -->' .claude/CLAUDE.md + [ ! -e .claude/rules ] +} + +@test "install refuses a module this release does not know and names the known ones" { + run "${kit_checkout}/bin/kit" install gemini + [ "$status" -eq 2 ] + [[ "$output" == *"the modules are: ci-github claude kit lefthook"* ]] + [ ! -e .kit/manifest ] +} + +@test "module add places a module's files, and module remove takes them and the kit's entries back out" { + mkdir .claude + printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json + "${kit_checkout}/bin/kit" install + run "${kit_checkout}/bin/kit" module list + [[ "$output" == *"✅ kit "* ]] + [[ "$output" == *" claude "* ]] + run "${kit_checkout}/bin/kit" module add claude + [ "$status" -eq 0 ] + jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null + grep --quiet '^modules kit claude$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" module remove claude + [ "$status" -eq 0 ] + [ ! -e .claude/CLAUDE.md ] + [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] + jq --exit-status '.permissions.deny == ["Bash(rm -rf /)"]' .claude/settings.json >/dev/null + jq --exit-status '.hooks == {"PreToolUse": []}' .claude/settings.json >/dev/null + grep --quiet '^modules kit$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "module remove refuses a module another active module depends on, and removes a leaf" { + "${kit_checkout}/bin/kit" install claude ci-github + run "${kit_checkout}/bin/kit" module remove kit + [ "$status" -eq 1 ] + [[ "$output" == *"kit cannot be removed while"* ]] + [ -f .kit/1st.md ] + run "${kit_checkout}/bin/kit" module remove ci-github + [ "$status" -eq 0 ] + [ ! -e .github/workflows/kit-ci.yml ] + grep --quiet '^modules kit claude$' .kit/manifest +} + +@test "a template takes the repository's answer over the module's default, and refuses a key with neither" { + mkdir .kit && printf 'answers:\n ci_runner: blacksmith-2vcpu\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" install ci-github + [ "$status" -eq 0 ] + grep --quiet '^ runs-on: "blacksmith-2vcpu"$' .github/workflows/kit-ci.yml + grep --quiet 'uses: actions/checkout@' .github/workflows/kit-ci.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + later-release "${repo}.later" + printf 'runner @ci_runner@ owner @owner@\n' > "${repo}.later/modules/ci-github/templates/repo/OWNER" + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *"OWNER needs an answer for 'owner'"* ]] + [ ! -e OWNER ] +} + +@test "a module whose metadata has a key the schema does not know is refused, naming the key" { + later-release "${repo}.later" + printf 'description: "x"\ndependz:\n - kit\n' > "${repo}.later/modules/lefthook/module.yml" + run "${repo}.later/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"modules/lefthook/module.yml has a key the kit does not know: dependz"* ]] + [ ! -e .kit/manifest ] + printf 'description: "x"\ndefault: yes\n' > "${repo}.later/modules/lefthook/module.yml" + run "${repo}.later/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"default must be true or false"* ]] +} + +@test "a module delivering to a base the kit does not know is refused, naming the bases" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/kit/files/etc" && printf 'x\n' > "${repo}.later/modules/kit/files/etc/.x" + run "${repo}.later/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"modules/kit/files/etc names a base the kit does not deliver to; the bases are: repo home"* ]] + [ ! -e .kit/manifest ] +} + +@test "a module delivering to home lands under it with a manifest of its own, and leaves with it" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/claude/files/home/.kit" + printf 'user level\n' > "${repo}.later/modules/claude/files/home/.kit/claude" + run "${repo}.later/bin/kit" install claude + [ "$status" -eq 0 ] + [ "$(cat "${KIT_HOME}/.kit/claude")" = "user level" ] + grep --quiet '^version 9.9.9$' "${KIT_HOME}/.kit/manifest" + grep --quiet ' .kit/claude$' "${KIT_HOME}/.kit/manifest" + ! grep --quiet 'claude$' .kit/manifest + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] + printf 'edited\n' >> "${KIT_HOME}/.kit/claude" + run "${repo}.later/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"/.kit/claude differs"* ]] + run "${repo}.later/bin/kit" module remove claude + [ "$status" -eq 0 ] + [ ! -e "${KIT_HOME}/.kit/claude" ] + [ ! -e "${KIT_HOME}/.kit/manifest" ] +} + +@test "check skips the home base where nothing was installed there, and says so" { + later-release "${repo}.later" + mkdir -p "${repo}.later/modules/kit/files/home/.kit" && printf 'x\n' > "${repo}.later/modules/kit/files/home/.kit/x" + "${repo}.later/bin/kit" install + rm -rf "${KIT_HOME}/.kit" + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] + [[ "$output" == *"home: nothing is installed under ${KIT_HOME} on this machine"* ]] +} + +@test "a module the repository defines under .kit/modules installs, checks, updates, and removes like a delivered one" { + mkdir -p .kit/modules/python/files/repo/.kit/rules .kit/modules/python/templates/repo + printf 'description: "The Python rule and the tool pins"\ndepends:\n - kit\nanswers:\n python: "3.13"\n' \ + > .kit/modules/python/module.yml + printf -- '---\npaths:\n - "**/*.py"\n---\n# Python\n\nType everything.\n' \ + > .kit/modules/python/files/repo/.kit/rules/python.md + printf 'python @python@\n' > .kit/modules/python/templates/repo/.python-version + run "${kit_checkout}/bin/kit" install python + [ "$status" -eq 0 ] + grep --quiet '^modules kit python$' .kit/manifest + [ "$(cat .python-version)" = "python 3.13" ] + run "${kit_checkout}/bin/kit" module list + [[ "$output" == *"python repository"* ]] + [[ "$output" == *"kit release"* ]] + run "${kit_checkout}/bin/kit" context app.py + [ "$output" = ".kit/rules/python.md" ] + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + printf 'edited\n' >> .kit/rules/python.md + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/python.md differs"* ]] + printf 'answers:\n python: "3.14"\n' > .kit/settings.yml + run env PATH="$(shims mise gh)" "${kit_checkout}/bin/kit" update + [ "$status" -eq 0 ] + [ "$(cat .python-version)" = "python 3.14" ] + grep --quiet '^edited$' .kit/rules/python.md + run "${kit_checkout}/bin/kit" module remove python + [ "$status" -eq 0 ] + [ ! -e .python-version ] + [ ! -e .kit/rules/python.md ] + [ -f .kit/modules/python/module.yml ] +} + +@test "the settings file is held to its schema: an unknown key and a non-boolean require_signing are refused" { + mkdir .kit && printf 'forgery: github\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *".kit/settings.yml has a key the kit does not know: forgery (see .kit/settings.schema.json)"* ]] + printf 'require_signing: yes\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"require_signing must be true or false"* ]] + printf 'require_signing: false\nanswers:\n ci_runner: x\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ -f .kit/settings.schema.json ] +} + +@test "a module the repository defines is validated like a delivered one" { + mkdir -p .kit/modules/odd && printf 'description: "x"\ndependz:\n - kit\n' > .kit/modules/odd/module.yml + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *".kit/modules/odd/module.yml has a key the kit does not know: dependz"* ]] +} + +@test "a region is placed inside the repository's own file, held to the release, and taken out whole" { + printf '# Ours\n\nOur own instructions.\n' > AGENTS.md + printf 'build/\n' > .gitignore + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ "$(sed -n '1,3p' AGENTS.md | paste -s -d '|' -)" = "# Ours||Our own instructions." ] + grep --quiet --line-regexp '<!-- kit:generate AGENTS.md -->' AGENTS.md + grep --quiet --line-regexp '<!-- kit:end AGENTS.md -->' AGENTS.md + grep --quiet 'Read .kit/1st.md at the start of every session' AGENTS.md + [ "$(sed -n '1p' .gitignore)" = "build/" ] + grep --quiet --line-regexp '/worktrees/' .gitignore + grep --quiet ' AGENTS.md$' .kit/manifest + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + printf '## More of ours\n' >> AGENTS.md + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + sed -i.bak 's/^Read .kit\/1st.md/Skip .kit\/1st.md/' AGENTS.md && rm AGENTS.md.bak + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"AGENTS.md: stale; run: kit generate"* ]] + run "${kit_checkout}/bin/kit" generate + [ "$status" -eq 0 ] + [[ "$output" == *"AGENTS.md: regenerated"* ]] + grep --quiet 'Read .kit/1st.md' AGENTS.md + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + # a region is what its command makes it: update regenerates an edited one rather than merging it + sed -i.bak 's/^Read .kit\/1st.md/Skip .kit\/1st.md/' AGENTS.md && rm AGENTS.md.bak + run env PATH="$(shims mise curl)" "${kit_checkout}/bin/kit" update + [ "$status" -eq 0 ] + [[ "$output" == *"AGENTS.md: the region regenerated"* ]] + grep --quiet 'Read .kit/1st.md' AGENTS.md + run "${kit_checkout}/bin/kit" module remove kit + [ "$status" -eq 0 ] + [ "$(cat AGENTS.md | paste -s -d '|' -)" = "# Ours||Our own instructions.|## More of ours" ] + [ "$(cat .gitignore)" = "build/" ] +} + +@test "a symlinked destination keeps its link, and a directory at a destination is refused" { + mkdir docs && printf '# Ours\n' > docs/AGENTS.md && ln -s docs/AGENTS.md AGENTS.md + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ -L AGENTS.md ] + grep --quiet --line-regexp '<!-- kit:generate AGENTS.md -->' docs/AGENTS.md + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" module remove kit + [ "$status" -eq 0 ] + mkdir -p .kit/1st.md + run "${kit_checkout}/bin/kit" module add kit + [ "$status" -eq 1 ] + [[ "$output" == *".kit/1st.md: a directory, or something that is not a file, is there"* ]] +} + +@test "a region that opens and never closes is refused by name, and two modules cannot share a region's file" { + printf '<!-- kit:generate AGENTS.md -->\nhalf\n' > AGENTS.md + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"AGENTS.md: the kit's region opens with '<!-- kit:generate AGENTS.md -->' and never closes"* ]] + rm AGENTS.md + mkdir -p .kit/modules/extra/regions/repo + printf 'description: "x"\ndepends:\n - kit\n' > .kit/modules/extra/module.yml + printf 'extra\n' > .kit/modules/extra/regions/repo/AGENTS.md + run "${kit_checkout}/bin/kit" install extra + [ "$status" -eq 1 ] + [[ "$output" == *"modules kit and extra both deliver a region to AGENTS.md"* ]] + printf 'x = 1\n' > .kit/modules/extra/regions/repo/config.toml + rm .kit/modules/extra/regions/repo/AGENTS.md + run "${kit_checkout}/bin/kit" install extra + [ "$status" -eq 0 ] + [ "$(sed -n '1p' config.toml)" = '# kit:generate config.toml' ] + [ "$("${kit_checkout}/bin/kit" region config.toml)" = "x = 1" ] + printf 'const x = 1;\n' > .kit/modules/extra/regions/repo/lib.js + run env PATH="$(shims mise curl)" "${kit_checkout}/bin/kit" update + [ "$status" -eq 0 ] + [ "$(sed -n '1p' lib.js)" = '// kit:generate lib.js' ] + [ "$(sed -n '3p' lib.js)" = '// kit:end lib.js' ] + run "${kit_checkout}/bin/kit" region AGENTS.md + [ "$status" -eq 0 ] + [[ "$output" == "Read .kit/1st.md"* ]] + run env KIT_GENERATE_REGION=AGENTS.md "${kit_checkout}/bin/kit" region + [ "$status" -eq 0 ] + [[ "$output" == "Read .kit/1st.md"* ]] + run "${kit_checkout}/bin/kit" region + [ "$status" -eq 2 ] + [[ "$output" == *"usage: kit region"* ]] + run "${kit_checkout}/bin/kit" region README.md + [ "$status" -eq 1 ] + [[ "$output" == *"no active module delivers a region at README.md"* ]] +} + +@test "a region makes the file where there is none, and one holding different content is regenerated" { + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 0 ] + [ "$(sed -n '1p' AGENTS.md)" = '<!-- kit:generate AGENTS.md -->' ] + run "${kit_checkout}/bin/kit" module remove kit + [ ! -e AGENTS.md ] + [ ! -e .gitignore ] + printf '# Ours\n\n<!-- kit:generate AGENTS.md -->\nsomething else\n<!-- kit:end AGENTS.md -->\n' > AGENTS.md + run "${kit_checkout}/bin/kit" module add kit + [ "$status" -eq 0 ] + [[ "$output" == *"AGENTS.md: placed, the region regenerated"* ]] + [ "$(sed -n '1p' AGENTS.md)" = "# Ours" ] + grep --quiet 'Read .kit/1st.md' AGENTS.md + ! grep --quiet 'something else' AGENTS.md +} + +@test "a module's expected include is named at install, refused by check until it is there, and never written" { + run "${kit_checkout}/bin/kit" install lefthook + [ "$status" -eq 0 ] + [[ "$output" == *"left to do in lefthook.yml: put extends: [.kit/lefthook.yml] in lefthook.yml"* ]] + [ ! -e lefthook.yml ] + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"lefthook.yml: put extends: [.kit/lefthook.yml] in lefthook.yml"* ]] + printf 'pre-commit:\n commands: {}\n' > lefthook.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + printf 'extends:\n - .kit/lefthook.yml\n' > lefthook.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + [ "$(cat lefthook.yml)" = "$(printf 'extends:\n - .kit/lefthook.yml')" ] +} + +@test "install keeps the repository's own settings keys and deny entries" { + mkdir .claude + printf '{"model":"x","permissions":{"deny":["Bash(rm -rf /)"]}}\n' > .claude/settings.json + "${kit_checkout}/bin/kit" install claude + [ "$(jq --raw-output .model .claude/settings.json)" = "x" ] + jq --exit-status '.permissions.deny | index("Bash(rm -rf /)")' .claude/settings.json >/dev/null + jq --exit-status '.permissions.deny | index("Bash(git switch main *)")' .claude/settings.json >/dev/null +} + +@test "install refuses a second time and names update" { + "${kit_checkout}/bin/kit" install + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *"run: kit update"* ]] +} + +@test "check passes after install and fails on a drifted, a missing, and a lost deny entry" { + "${kit_checkout}/bin/kit" install claude lefthook + printf 'extends:\n - .kit/lefthook.yml\n' > lefthook.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + printf 'edited\n' >> .kit/rules/yaml.md + rm .kit/lefthook.yml + jq '.permissions.deny -= ["Bash(git reset --hard *)"]' .claude/settings.json > s && mv s .claude/settings.json + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/yaml.md differs"* ]] + [[ "$output" == *".kit/lefthook.yml is missing"* ]] + [[ "$output" == *"missing an entry the kit writes"* ]] + [[ "$output" == *"3 finding(s)"* ]] +} + +@test "check refuses without a manifest and names install" { + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"run: kit install"* ]] +} + +@test "update replaces unchanged files, merges an edited one, and records the release's digests" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + # a consumer edit to a file the later release also changes, on a different line + sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 0 ] + grep --quiet '^version 9.9.9$' .kit/manifest + grep --quiet '^# JSON (ours)$' .kit/rules/json.md + grep --quiet '^## Added in 9.9.9$' .kit/rules/json.md + # the manifest carries the release's digest, so the edit still reads as an edit + release_digest=$(shasum -a 256 "${repo}.later/modules/kit/files/repo/.kit/rules/json.md" | cut -d ' ' -f 1) + grep --quiet "^${release_digest} .kit/rules/json.md$" .kit/manifest + # and check names the one file that still differs from the release + run "${repo}.later/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/json.md differs"* ]] + [[ "$output" == *"1 finding(s)"* ]] +} + +@test "update refuses a path new in the release where the repository already has a different file" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + printf 'new rule\n' > "${repo}.later/modules/kit/files/repo/.kit/rules/toml.md" + printf 'mine\n' > .kit/rules/toml.md + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *".kit/rules/toml.md: a different file is there already"* ]] + [ "$(cat .kit/rules/toml.md)" = "mine" ] +} + +@test "update of an untouched install leaves check green" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 0 ] + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "update leaves conflict markers and fails when the same lines changed" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + theirs="${repo}.later/modules/kit/files/repo/.kit/rules/json.md" + sed -i.bak 's/^# JSON$/# JSON (theirs)/' "${theirs}" && rm "${theirs}.bak" + sed -i.bak 's/^# JSON$/# JSON (ours)/' .kit/rules/json.md && rm .kit/rules/json.md.bak + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 1 ] + grep --quiet '^<<<<<<<' .kit/rules/json.md + [[ "$output" == *"conflict markers"* ]] +} + +@test "update retires a path the new release no longer delivers, unless it was edited" { + "${kit_checkout}/bin/kit" install claude + later-release "${repo}.later" + rm "${repo}.later/modules/kit/files/repo/.kit/rules/yaml.md" "${repo}.later/modules/kit/files/repo/.kit/rules/json.md" + printf 'mine too\n' >> .kit/rules/json.md + run env KIT_BASE="${kit_checkout}" "${repo}.later/bin/kit" update + [ "$status" -eq 0 ] + [ ! -e .kit/rules/yaml.md ] + [ -e .kit/rules/json.md ] + [[ "$output" == *".kit/rules/yaml.md: retired in 9.9.9, removed"* ]] + [[ "$output" == *".kit/rules/json.md: retired in 9.9.9, but edited"* ]] + ! grep --quiet 'rules/yaml.md' .kit/manifest + run "${repo}.later/bin/kit" check + [ "$status" -eq 0 ] +} + +@test "update without the base release refuses an edited file, names the base, and leaves the manifest's version" { + later-release "${repo}.later" + "${repo}.later/bin/kit" install claude + grep --quiet '^version 9.9.9$' .kit/manifest + mkdir -p "${repo}.newer" && cp -R "${repo}.later/bin" "${repo}.later/modules" "${repo}.newer/" + printf '9.9.10\n' > "${repo}.newer/version" + printf 'Added in 9.9.10.\n' >> "${repo}.newer/modules/kit/files/repo/.kit/rules/json.md" + printf 'edited\n' >> .kit/rules/bash.md + # neither the cache, nor mise, nor the release can produce 9.9.9 + run env PATH="$(shims mise curl)" "${repo}.newer/bin/kit" update + [ "$status" -eq 1 ] + [[ "$output" == *"could not be fetched"* ]] + [[ "$output" == *".kit/rules/bash.md cannot be updated without the base release 9.9.9"* ]] + grep --quiet '^version 9.9.9$' .kit/manifest + # whole or not at all: the file that needed no base was not written either + ! grep --quiet 'Added in 9.9.10' .kit/rules/json.md + rm -rf "${repo}.newer" +} + +@test "update without the base release replaces every file still as it was placed, where none is merged by key" { + later-release "${repo}.later" + "${repo}.later/bin/kit" install + mkdir -p "${repo}.newer" && cp -R "${repo}.later/bin" "${repo}.later/modules" "${repo}.newer/" + printf '9.9.10\n' > "${repo}.newer/version" + printf 'Added in 9.9.10.\n' >> "${repo}.newer/modules/kit/files/repo/.kit/rules/json.md" + run env PATH="$(shims mise curl)" "${repo}.newer/bin/kit" update + [ "$status" -eq 0 ] + grep --quiet 'Added in 9.9.10' .kit/rules/json.md + grep --quiet '^version 9.9.10$' .kit/manifest + run "${repo}.newer/bin/kit" check + [ "$status" -eq 0 ] + rm -rf "${repo}.newer" +} + +@test "update at the same version uses the release as its own base, and a second run changes nothing" { + "${kit_checkout}/bin/kit" install claude + printf 'edited\n' >> .kit/rules/bash.md + run env PATH="$(shims mise gh)" "${kit_checkout}/bin/kit" update + [ "$status" -eq 0 ] + grep --quiet '^edited$' .kit/rules/bash.md + run env PATH="$(shims mise gh)" "${kit_checkout}/bin/kit" update + [ "$status" -eq 0 ] + [[ "$output" != *"conflict"* ]] +} + +@test "the verbs run from a subdirectory as from the root" { + "${kit_checkout}/bin/kit" install + mkdir -p docs/deep && cd docs/deep + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" module add claude + [ "$status" -eq 0 ] + [ -f "${repo}/.claude/settings.json" ] + [ ! -e .claude ] +} + +@test "install and module add refuse a different file at a delivered path, and accept the same one" { + mkdir .kit && printf 'mine\n' > .kit/1st.md + run "${kit_checkout}/bin/kit" install + [ "$status" -eq 1 ] + [[ "$output" == *".kit/1st.md: a different file is there already"* ]] + [ ! -e .kit/manifest ] + [ "$(cat .kit/1st.md)" = "mine" ] + rm .kit/1st.md + "${kit_checkout}/bin/kit" install + printf 'theirs\n' > .kit/lefthook.yml + run "${kit_checkout}/bin/kit" module add lefthook + [ "$status" -eq 1 ] + [[ "$output" == *".kit/lefthook.yml: a different file is there already"* ]] + grep --quiet '^modules kit$' .kit/manifest + rm .kit/lefthook.yml + mkdir .claude && printf '# Ours\n' > .claude/CLAUDE.md + run "${kit_checkout}/bin/kit" module add claude lefthook + [ "$status" -eq 0 ] + [ "$(sed -n '1p' .claude/CLAUDE.md)" = "# Ours" ] + grep --quiet --line-regexp '@../AGENTS.md' .claude/CLAUDE.md +} + +@test "module remove judges dependents against what stays, in any order" { + "${kit_checkout}/bin/kit" install claude + run "${kit_checkout}/bin/kit" module remove kit lefthook claude + [ "$status" -eq 0 ] + grep --quiet '^modules$' .kit/manifest + [ ! -e .kit/1st.md ] +} + +@test "a home manifest another repository wrote is left alone, and its surviving lines are kept" { + mkdir -p "${KIT_HOME}/.kit" && printf 'theirs\n' > "${KIT_HOME}/.kit/theirs" + digest=$(shasum -a 256 "${KIT_HOME}/.kit/theirs" | cut -d ' ' -f 1) + printf '# manifest\nversion 0.0.1\nmodules other\n%s .kit/theirs\n' "${digest}" > "${KIT_HOME}/.kit/manifest" + "${kit_checkout}/bin/kit" install + [ -f "${KIT_HOME}/.kit/theirs" ] + grep --quiet ' .kit/theirs$' "${KIT_HOME}/.kit/manifest" + grep --quiet '^version 0.0.1$' "${KIT_HOME}/.kit/manifest" +} + +@test "a region is a template: @key@ takes the repository's answer, the module's default, or a settings key, and refuses a key with none" { + mkdir -p .kit/modules/extra/regions/repo + printf 'description: "x"\ndepends:\n - kit\nanswers:\n ci_runner: ours\n' > .kit/modules/extra/module.yml + printf 'runner = "@ci_runner@"\nsigning = @require_signing@\nagain = "@answers.ci_runner@"\n' > .kit/modules/extra/regions/repo/config.toml + printf 'require_signing: false\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" install extra + [ "$status" -eq 0 ] + [ "$(sed -n '2,4p' config.toml | paste -s -d '|' -)" = 'runner = "ours"|signing = false|again = "ours"' ] + [ "$("${kit_checkout}/bin/kit" region config.toml | paste -s -d '|' -)" = 'runner = "ours"|signing = false|again = "ours"' ] + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 0 ] + printf 'require_signing: false\nanswers:\n ci_runner: theirs\n' > .kit/settings.yml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"config.toml: stale"* ]] + run "${kit_checkout}/bin/kit" generate + [ "$status" -eq 0 ] + grep --quiet '^runner = "theirs"$' config.toml + grep --quiet '^again = "theirs"$' config.toml + printf 'x = @missing@\n' > .kit/modules/extra/regions/repo/other.toml + run "${kit_checkout}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"other.toml needs an answer for 'missing'; add 'missing: <value>' under answers: in .kit/settings.yml (module extra)"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/linked.bats b/tests/linked.bats new file mode 100644 index 0000000..0320de0 --- /dev/null +++ b/tests/linked.bats @@ -0,0 +1,28 @@ +#!/usr/bin/env bats +# linked.bats - kit linked names the pull request it needs when none can be found; the rest needs GitHub +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${BATS_TEST_TMPDIR}/shims" + printf '#!/usr/bin/env bash\nexit 1\n' > "${BATS_TEST_TMPDIR}/shims/gh" && chmod +x "${BATS_TEST_TMPDIR}/shims/gh" + export PATH="${BATS_TEST_TMPDIR}/shims:${PATH}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + git remote add origin https://github.com/empowerite/example.git + export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/summary.md" +} + +teardown() { + rm -rf "${repo}" +} + +@test "linked names the pull request it needs when none can be found" { + run env -u GITHUB_EVENT_PATH "${kit_checkout}/bin/kit" linked + [ "$status" -eq 2 ] + [[ "$output" == *"kit linked <number>"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/lint.bats b/tests/lint.bats new file mode 100644 index 0000000..968f7af --- /dev/null +++ b/tests/lint.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# lint.bats - kit lint runs each rule's linter over the paths it governs, staged, named, or every tracked one +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + shellcheck --version >/dev/null 2>&1 || skip "shellcheck is the bash rule's linter, and it is not on PATH" + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + "${kit_checkout}/bin/kit" install >/dev/null + script bad.sh 'echo $x' + script good.sh 'echo "${x:-}"' + script tool 'echo $y' +} + +# script <path> <line> writes an executed bash script in the bash template's shape, its body the one line, so the +# only thing a test's linter can refuse is that line. +script() { + local rule + printf -v rule '# %s' "$(printf '%.0s-' $(seq 1 118))" + printf '#!/usr/bin/env bash\n# %s - a fixture\n%s\n\nset -o errexit -o nounset -o pipefail\n\n%s\n\n%s\n' \ + "$1" "${rule}" "$2" "${rule}" > "$1" +} + +teardown() { + rm -rf "${repo}" +} + +@test "lint refuses a staged bash file shellcheck faults, and passes a clean one" { + git add bad.sh + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] + [[ "$output" == *".kit/rules/bash.md: shellcheck"* ]] + git reset --quiet && git add good.sh + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 0 ] + [[ "$output" == *"every governed path passes"* ]] +} + +@test "a named path is linted by the rule its first line names, and --all-files reads every tracked path" { + run "${kit_checkout}/bin/kit" lint tool + [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] + git add good.sh + git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false commit --quiet --no-verify --message good + git add bad.sh + git -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false commit --quiet --no-verify --message bad + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 0 ] + [[ "$output" == *"nothing to lint"* ]] + run "${kit_checkout}/bin/kit" lint --all-files + [ "$status" -eq 1 ] + [[ "$output" == *"bad.sh"* ]] +} + +@test "a named path is taken from the directory the command was typed in, and the staged list from the root" { + mkdir sub && cp bad.sh sub/bad.sh && git add sub/bad.sh + cd sub + run "${kit_checkout}/bin/kit" lint + [ "$status" -eq 1 ] + [[ "$output" == *"sub/bad.sh"* ]] + run "${kit_checkout}/bin/kit" lint bad.sh + [ "$status" -eq 1 ] + [[ "$output" == *"SC2086"* ]] +} + +@test "the delivered markdown passes the delivered markdown linter in a fresh repository" { + command -v markdownlint-cli2 >/dev/null || skip "markdownlint-cli2, the markdown rule's linter, is not on PATH" + run "${kit_checkout}/bin/kit" lint .kit/1st.md .kit/rules/bash.md AGENTS.md + [ "$status" -eq 0 ] + [[ "$output" == *".kit/markdownlint.jsonc"* ]] +} + +@test "a linter a rule names that is not on PATH is a refusal, named" { + mkdir -p "${BATS_TEST_TMPDIR}/path" && ln -s "$(command -v git)" "${BATS_TEST_TMPDIR}/path/git" + for tool in bash jq awk sed grep sort cut paste tr find head tail cat mkdir rm mv cp dirname basename date env uname \ + readlink comm + do + p=$(command -v "${tool}") && ln -s "${p}" "${BATS_TEST_TMPDIR}/path/${tool}" || true + done + git add good.sh + run env PATH="${BATS_TEST_TMPDIR}/path" "${kit_checkout}/bin/kit" lint + [ "$status" -eq 1 ] + [[ "$output" == *"shellcheck is not on PATH"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/policy.bats b/tests/policy.bats new file mode 100644 index 0000000..c91a5c7 --- /dev/null +++ b/tests/policy.bats @@ -0,0 +1,139 @@ +#!/usr/bin/env bats +# policy.bats - the facts, the patterns and their defaults, and two engines that refuse the same things the same way +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + export FAKE_DIR="${BATS_TEST_TMPDIR}/fake" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${FAKE_DIR}/bin" + ln -s "${BATS_TEST_DIRNAME}/fakes/fake-tool" "${FAKE_DIR}/bin/gh" + : > "${FAKE_DIR}/gh.routes" + export PATH="${FAKE_DIR}/bin:${PATH}" + unset GITHUB_HEAD_REF CI_MERGE_REQUEST_SOURCE_BRANCH_NAME GITHUB_EVENT_PATH + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + git commit --quiet --allow-empty --message "#1 start: the first" && git push --quiet origin main + git remote set-head origin main + mkdir .kit +} + +teardown() { + rm -rf "${work}" +} + +fixture() { cat > "${FAKE_DIR}/$1"; } +route() { printf '%s\t%s\n' "$2" "$3" >> "${FAKE_DIR}/$1.routes"; } + +# branch <name> <subject>… makes a branch past main with one empty commit per subject. +branch() { + git switch --quiet --create "$1" + shift + for subject in "$@" + do + git commit --quiet --allow-empty --message "${subject}" + done +} + +# change <title> makes the forge, GitHub by the setting since origin is a local path, answer a change on the +# branch with that title. +change() { + grep --quiet '^forge:' .kit/settings.yml 2>/dev/null || printf 'forge: github\n' >> .kit/settings.yml + fixture pr.json <<<"{\"number\": 3, \"title\": \"$1\", \"body\": \"Closes #7\"}" + route gh 'pr view --json number*' pr.json + route gh 'pr view 3 --json *' pr.json +} + +@test "the facts are the branch, the commits past the fork, the change, and the rules with the kit's defaults" { + branch 7-fix-the-thing "#7 cli: fix the thing" "AB#4521 docs: say so" + change "Fix the thing" + run "${kit_root}/bin/kit" policy --input + [ "$status" -eq 0 ] + [ "$(jq --raw-output .branch <<<"$output")" = "7-fix-the-thing" ] + [ "$(jq --raw-output '[.commits[].subject] | join("|")' <<<"$output")" = "AB#4521 docs: say so|#7 cli: fix the thing" ] + [ "$(jq --raw-output .change.title <<<"$output")" = "Fix the thing" ] + [ "$(jq --raw-output '.policy | keys | join(" ")' <<<"$output")" = "branch change_title commit_subject" ] + run "${kit_root}/bin/kit" policy + [ "$status" -eq 0 ] + [[ "$output" == *"the patterns policy allows the branch, 2 commit(s), the change title"* ]] +} + +@test "the defaults refuse a branch, a commit subject, and a title off the loop's shape, each named" { + branch topic "#7 cli: fine" "wip" + change "" + run "${kit_root}/bin/kit" policy + [ "$status" -eq 1 ] + [[ "$output" == *"branch 'topic' does not match policy.branch"* ]] + [[ "$output" == *"'wip' does not match policy.commit_subject"* ]] + [[ "$output" == *"change title '' does not match policy.change_title"* ]] + [[ "$output" == *"3 refusal(s) by the patterns policy"* ]] +} + +@test "the repository's patterns override the defaults, an empty one turns a rule off, and an unknown subject is refused" { + branch feature/login "wip" + printf "policy:\n branch: '^feature/[a-z-]+\$'\n commit_subject: ''\n" > .kit/settings.yml + run "${kit_root}/bin/kit" policy + [ "$status" -eq 0 ] + printf "policy:\n branche: '^x\$'\n" > .kit/settings.yml + run "${kit_root}/bin/kit" policy + [ "$status" -ne 0 ] + [[ "$output" == *"policy has a subject the kit does not know: branche"* ]] +} + +@test "on the default branch, or detached, nothing is under check" { + run "${kit_root}/bin/kit" policy + [ "$status" -eq 0 ] + [[ "$output" == *"allows 0 commit(s)"* ]] + git commit --quiet --allow-empty --message "not checked" + git switch --quiet --detach + run "${kit_root}/bin/kit" policy + [ "$status" -eq 0 ] +} + +@test "the opa engine, given the policy-opa module's Rego, refuses exactly what the patterns engine does" { + command -v opa >/dev/null || skip "opa, the opa policy engine, is not on PATH" + "${kit_root}/bin/kit" install policy-opa >/dev/null 2>&1 + [ -f .kit/policy/kit.rego ] + branch topic "#7 cli: fine" "wip" "Also wrong" + change "A title that is fine" + run "${kit_root}/bin/kit" policy + [ "$status" -eq 1 ] + [[ "$output" == *"by the opa policy"* ]] + by_opa=$(grep '^❌ ' <<<"$output" | grep --invert-match 'refusal(s)' | LC_ALL=C sort) + printf 'forge: github\npolicy_engine: patterns\n' > .kit/settings.yml + run "${kit_root}/bin/kit" policy + [ "$status" -eq 1 ] + by_patterns=$(grep '^❌ ' <<<"$output" | grep --invert-match 'refusal(s)' | LC_ALL=C sort) + [ "$(wc -l <<<"${by_opa}")" -eq 3 ] + [ "${by_opa}" = "${by_patterns}" ] +} + +@test "a rule of the repository's own in Rego adds to the kit's, and a policy engine the kit does not know is refused" { + command -v opa >/dev/null || skip "opa, the opa policy engine, is not on PATH" + "${kit_root}/bin/kit" install policy-opa >/dev/null 2>&1 + cat > .kit/policy/ours.rego <<'REGO' +package kit.policy + +import rego.v1 + +deny contains msg if { + count(input.commits) > 2 + msg := sprintf("%d commits; split the change", [count(input.commits)]) +} +REGO + branch 7-many "#7 a: one" "#7 a: two" "#7 a: three" + run "${kit_root}/bin/kit" policy + [ "$status" -eq 1 ] + [[ "$output" == *"3 commits; split the change"* ]] + printf 'policy_engine: cue\n' > .kit/settings.yml + run "${kit_root}/bin/kit" policy + [ "$status" -ne 0 ] + [[ "$output" == *"'cue' is not a policy engine the kit knows"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/rebase.bats b/tests/rebase.bats new file mode 100644 index 0000000..6ec8635 --- /dev/null +++ b/tests/rebase.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# rebase.bats - kit rebase refuses the desk and the default branch, and brings a worktree's branch up to date +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main +} + +teardown() { + rm -rf "${work}" +} + +# worktree makes a worktree on branch 1-x beside the desk, configured to commit. +worktree() { + git worktree add --quiet ../wt -b 1-x + git -C ../wt config user.name t + git -C ../wt config user.email t@example.com + git -C ../wt config commit.gpgsign false +} + +# commit-in <checkout> <file> <content> writes the file and commits it there. +commit-in() { + printf '%s\n' "$3" > "$1/$2" + git -C "$1" add "$2" + git -C "$1" commit --quiet --message "$2" +} + +@test "rebase refuses the root checkout, whatever branch it has" { + run "${kit_checkout}/bin/kit" rebase + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase main: it is the root checkout"* ]] + git switch --quiet --create desk-topic + run "${kit_checkout}/bin/kit" rebase + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase desk-topic: it is the root checkout"* ]] +} + +@test "rebase refuses the default branch in a worktree" { + git switch --quiet --create desk-topic + git worktree add --quiet ../wt main + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to rebase main: it is the default branch"* ]] +} + +@test "rebase moves a worktree's branch onto origin/main and pushes it" { + worktree + commit-in ../wt b two && git -C ../wt push --quiet --set-upstream origin 1-x + commit-in . c three && git push --quiet origin main + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 0 ] + [ "$(git -C ../wt rev-parse HEAD~1)" = "$(git rev-parse origin/main)" ] + [ "$(git -C ../wt rev-parse HEAD)" = "$(git -C ../wt rev-parse origin/1-x)" ] +} + +@test "rebase aborts on a conflict and leaves the worktree as it was" { + worktree + commit-in ../wt a theirs && git -C ../wt push --quiet --set-upstream origin 1-x + commit-in . a ours && git push --quiet origin main + before=$(git -C ../wt rev-parse HEAD) + run "${kit_checkout}/bin/kit" rebase ../wt + [ "$status" -eq 1 ] + [[ "$output" == *"conflicts with origin/main"* ]] + [ "$(git -C ../wt rev-parse HEAD)" = "${before}" ] + [ -z "$(git -C ../wt status --porcelain)" ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/release.bats b/tests/release.bats new file mode 100644 index 0000000..0190afd --- /dev/null +++ b/tests/release.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# release.bats - kit release verifies a tag and packs a checkout as a release that installs from its unpacked directory +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + kit_checkout=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + work=$(mktemp -d) + cd "${work}" +} + +teardown() { + rm -rf "${work}" +} + +@test "verify holds a tag to its shape and its side of the default branch" { + git init --quiet --bare --initial-branch=main origin.git && git clone --quiet origin.git desk 2>/dev/null && cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main + git tag release/1.2.3 && git tag release/1.2.3-rc.1 && git tag release/nope && git tag v1.2.3 + git switch --quiet --create topic && printf 'two\n' > b && git add b && git commit --quiet --message two + git tag release/1.2.4 && git tag release/1.2.4-rc.1 + run "${kit_checkout}/bin/kit" release verify release/1.2.3 + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" release verify release/1.2.4-rc.1 + [ "$status" -eq 0 ] + run "${kit_checkout}/bin/kit" release verify release/1.2.3-rc.1 + [ "$status" -eq 1 ] + [[ "$output" == *"is a pre-release tag on main"* ]] + run "${kit_checkout}/bin/kit" release verify release/1.2.4 + [ "$status" -eq 1 ] + [[ "$output" == *"is not on main"* ]] + run "${kit_checkout}/bin/kit" release verify release/nope + [ "$status" -eq 1 ] + [[ "$output" == *"is not a release tag"* ]] + run "${kit_checkout}/bin/kit" release verify v1.2.3 + [ "$status" -eq 1 ] + [[ "$output" == *"is not a release tag"* ]] +} + +@test "publish refuses a release from a desk, and names the push that has the runner publish it" { + run env -u GITHUB_ACTIONS "${kit_checkout}/bin/kit" release publish release/1.2.3 + [ "$status" -eq 1 ] + [[ "$output" == *"a release is published by the release workflow, not from a desk"* ]] + [[ "$output" == *"git push origin release/1.2.3"* ]] +} + +@test "pack makes one directory with the release's layout and the version stamped in, which installs a repository" { + version=9.9.9 + run "${kit_checkout}/bin/kit" release pack "release/${version}" "${work}" + [ "$status" -eq 0 ] + [ -f "kit-${version}.tar.gz.sha256" ] + grep --quiet " kit-${version}.tar.gz$" "kit-${version}.tar.gz.sha256" + mkdir unpacked && tar --extract --gzip --file "kit-${version}.tar.gz" --directory unpacked + [ "$(ls unpacked)" = "kit-${version}" ] + [ -x "unpacked/kit-${version}/bin/kit" ] + [ -x "unpacked/kit-${version}/bin/kit-install.sh" ] + [ -f "unpacked/kit-${version}/modules/claude/module.yml" ] + mkdir fresh && git -C fresh init --quiet --initial-branch=main + cd fresh + run "${work}/unpacked/kit-${version}/bin/kit" install claude + [ "$status" -eq 0 ] + run "${work}/unpacked/kit-${version}/bin/kit" check + [ "$status" -eq 0 ] + [ "$("${work}/unpacked/kit-${version}/bin/kit" version)" = "${version}" ] + grep --quiet "^version ${version}$" .kit/manifest +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/status.bats b/tests/status.bats new file mode 100644 index 0000000..0232104 --- /dev/null +++ b/tests/status.bats @@ -0,0 +1,191 @@ +#!/usr/bin/env bats +# status.bats - the parts of the picture that need no GitHub: a cell, a checkout's measures and block, its label, +# and its path from the current directory +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main + mkdir worktrees +} + +teardown() { + rm -rf "${work}" +} + +# in_status <command>… runs a command with the status module loaded, from this directory, as a verb would. +in_status() { + kit_invoked_in=$(pwd -P) bash -c 'source "${kit_root}/bin/1st.sh" && use status && "$@"' _ "$@" +} + +# commits-in <path> <n> makes <n> commits in the checkout at <path>. +commits-in() { + local i + for (( i = 1; i <= $2; i++ )) + do + printf '%s\n' "${i}" > "$1/f${i}" + git -C "$1" add . && git -C "$1" commit --quiet --message "c${i}" + done +} + +@test "a cell is padded to its width, or clipped with an ellipsis" { + run in_status status:cell abc 5 + [ "$output" = "abc " ] + run in_status status:cell abcdefgh 5 + [ "$output" = "abcd…" ] +} + +@test "a checkout is measured from where its branch left origin's default branch" { + git worktree add --quiet -b topic worktrees/topic main + printf 'two\nthree\n' > worktrees/topic/b + git -C worktrees/topic add b && git -C worktrees/topic commit --quiet --message two + printf 'four\n' > c && git add c && git commit --quiet --message four && git push --quiet origin main + printf 'x\n' > worktrees/topic/x + run in_status status:checkout "${PWD}/worktrees/topic" topic main + [ "$status" -eq 0 ] + fields='[.relative, .branch, .ahead, .behind, .added, .removed, .uncommitted, (.commits | length)] | @tsv' + [ "$(jq -r "${fields}" <<<"$output")" = "$(printf 'worktrees/topic\ttopic\t1\t1\t2\t0\t1\t1')" ] + [ "$(jq --compact-output .changes <<<"$output")" = '[{"status":"??","path":"x"}]' ] +} + +@test "a block shows the newest commits, counts the hidden ones, and ends with the oldest" { + git worktree add --quiet -b topic worktrees/topic main + commits-in worktrees/topic 15 + doc=$(in_status status:checkout "${PWD}/worktrees/topic" topic main) + run in_status status:checkout-block 🌿 "${doc}" 15 + [ "$status" -eq 0 ] + [ "${lines[0]}" = "🌿 worktrees/topic · ↑15 ↓0 · +15 -0" ] + [ "${#lines[@]}" -eq 12 ] + [[ "${lines[1]}" == *" c15" ]] + [[ "${lines[9]}" == *" c7" ]] + [[ "${lines[10]}" == *"… and 5 more" ]] + [[ "${lines[11]}" == *" c1" ]] +} + +@test "the desk block is its branch, its unpushed commits, and its uncommitted paths" { + printf 'two\n' > b && git add b && git commit --quiet --message two + printf 'x\n' > c + doc=$(in_status status:checkout "${PWD}" main main) + run in_status status:checkout-block 🏠 "${doc}" 4 + [ "$status" -eq 0 ] + [ "${lines[0]}" = "🏠 main · ↑1 ↓0 · +1 -0 · 1 uncommitted" ] + [ "${lines[1]}" = " ?? c" ] + [[ "${lines[2]}" == *" two" ]] + [ "${#lines[@]}" -eq 3 ] +} + +@test "a block lists its uncommitted paths with git's status, the first ten and a count of the rest" { + printf 'changed\n' > a + for i in $(seq 1 12); do printf '%s\n' "${i}" > "n${i}"; done + doc=$(in_status status:checkout "${PWD}" main main) + run in_status status:checkout-block 🏠 "${doc}" 4 + [ "${lines[0]}" = "🏠 main · ↑0 ↓0 · +1 -1 · 13 uncommitted" ] + [ "${lines[1]}" = " M a" ] + [ "${lines[2]}" = " ?? n1" ] + [ "${lines[11]}" = " … and 3 more" ] + [ "${#lines[@]}" -eq 12 ] +} + +@test "the fetch's line names the commits new on the default branch and the branches and tags it moved" { + git push --quiet origin main:old main:stays + git fetch --quiet origin + git clone --quiet ../origin.git ../other 2>/dev/null + ( + cd ../other + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'b\n' > b && git add b && git commit --quiet --message b + printf 'c\n' > c && git add c && git commit --quiet --message c + git push --quiet origin main main:fresh main:stays HEAD:refs/tags/v1 + git push --quiet origin --delete old + ) + run in_status status:fetch main + [ "$status" -eq 0 ] + expected='📥 fetched origin: 2 new commits on main · 1 branch updated · 1 new branch · 1 branch pruned' + [ "${lines[0]}" = "${expected} · 1 new tag" ] + run in_status status:fetch main + [ "${lines[0]}" = "📥 fetched origin: nothing new" ] +} + +@test "kit status prints the fetch first, then the checkouts, then what the forge said, held until its place" { + printf 'x\n' > c + run "${kit_root}/bin/kit" status + [ "${lines[0]}" = "📥 fetched origin: nothing new" ] + [ "${lines[1]}" = "🏠 main · ↑0 ↓0 · +0 -0 · 1 uncommitted" ] + [ "${lines[2]}" = " ?? c" ] + [[ "${lines[3]}" == *"forge"* ]] +} + +@test "a detached checkout is marked and lists no commits" { + git worktree add --quiet --detach worktrees/loose main + doc=$(in_status status:checkout "${PWD}/worktrees/loose" '' main) + run in_status status:checkout-block 🌿 "${doc}" 15 + [ "$output" = "🌿 worktrees/loose · ❗ detached HEAD · ↑0 ↓0 · +0 -0" ] +} + +@test "the counts of every checkout sit in columns, ahead and added right-aligned, behind and removed left-aligned" { + one=$(jq -nc '{ahead: 1, behind: 22, added: 3, removed: 4444, uncommitted: 0}') + two=$(jq -nc '{ahead: 100, behind: 2, added: 30000, removed: 4, uncommitted: 2}') + widths=$(in_status status:count-widths "${one}" "${two}") + [ "${widths}" = "4 3 6 5" ] + run in_status status:measures "${one}" "${widths}" + [ "$output" = " ↑1 ↓22 · +3 -4444" ] + run in_status status:measures "${two}" "${widths}" + [ "$output" = "↑100 ↓2 · +30000 -4 · 2 uncommitted" ] +} + +@test "a label is the path when the branch ends it, the branch at the current directory, or branch on path" { + run in_status status:label worktrees/topic topic + [ "$output" = "$(printf 'worktrees/topic\t15')" ] + run in_status status:label . main + [ "$output" = "$(printf 'main\t4')" ] + run in_status status:label ../other topic + [ "$output" = "$(printf 'topic on ../other\t17')" ] +} + +@test "a pull request's row carries its author when asked and ends with its branch, and the issues it closes follow" { + doc='{"number":2,"title":"The first","author":"ada","branch":"2-the-first","state":"queued","age":259200,' + doc+='"unresolved":0,"checks":{"state":"PENDING","passed":0,"total":1,"failing":0,"took":36},' + doc+='"closes":[{"number":1,"title":"One"},{"number":4,"title":"Four"}]}' + run in_status status:pr-block "${doc}" 12 0 6 2 worktrees/2-the-first + [ "${lines[0]}" = "🔀 #2 The first · 🚦 queued 3d · 🏃 0 / 1 36s · 🌿 2-the-first" ] + [ "${lines[1]}" = " closes #1 One" ] + [ "${lines[2]}" = " closes #4 Four" ] + [ "${#lines[@]}" -eq 3 ] + run in_status status:pr-block "${doc}" 12 5 6 2 '' + [ "${lines[0]}" = "🔀 #2 The first · 👤 ada · 🚦 queued 3d · 🏃 0 / 1 36s" ] +} + +@test "a failed checks cell names the failing count, then passed of total, then the time taken" { + run in_status status:check-cell '{"state":"FAILURE","passed":3,"total":5,"failing":2,"took":31}' + [ "$output" = "❌ 2 failing 3 / 5 31s" ] +} + +@test "the pull requests shown are everyone's, or the named authors' alone" { + docs=$(printf '{"number":1,"author":"ada"}\n{"number":2,"author":"bob"}\n{"number":3,"author":"cy"}\n') + run in_status status:select <<<"${docs}" + [ "$(jq --slurp --compact-output 'map(.number)' <<<"$output")" = "[1,2,3]" ] + run in_status status:select ada cy <<<"${docs}" + [ "$(jq --slurp 'map(.number) | join(",")' --raw-output <<<"$output")" = "1,3" ] + run in_status status:select nobody <<<"${docs}" + [ -z "$output" ] +} + +@test "a path is printed relative to the current directory" { + run in_status status:relative "${PWD}" + [ "$output" = "." ] + run in_status status:relative "${PWD}/worktrees/topic" + [ "$output" = "worktrees/topic" ] + cd worktrees + run in_status status:relative "${PWD%/worktrees}" + [ "$output" = ".." ] +} diff --git a/tests/template.bats b/tests/template.bats new file mode 100644 index 0000000..c3350cb --- /dev/null +++ b/tests/template.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats +# template.bats - a rule's template makes a new file, kit new file, and holds every file to its shape, kit lint +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + "${kit_root}/bin/kit" install >/dev/null 2>&1 +} + +teardown() { + rm -rf "${repo}" +} + +# in_template <command>… runs a command with the template module loaded, from the repository, as a verb would. +in_template() { + bash -c 'source "${kit_root}/bin/1st.sh" && use template && "$@"' _ "$@" +} + +# shape <path>… runs only the shape check of every rule governing the paths, as kit lint does, without the linters. +shape() { + local path rule status=0 + for path in "$@" + do + while IFS= read -r rule + do + in_template template:check "${rule}" "${path}" || status=1 + done < <(bash -c 'source "${kit_root}/bin/1st.sh" && use context && context:rules "$1"' _ "${path}") + done + return "${status}" +} + +@test "a new file is made at the root from its rule's first template, its holes filled, and made executable" { + mkdir -p deep/er + cd deep/er + run "${kit_root}/bin/kit" new file bin/deploy.sh --purpose="push the build" + [ "$status" -eq 0 ] + [[ "$output" == *"made bin/deploy.sh from .kit/templates/bash/script"* ]] + [[ "$output" == *".kit/rules/bash.md"* ]] + cd "${repo}" + [ -x bin/deploy.sh ] + [ "$(sed -n 1,2p bin/deploy.sh)" = "$(printf '#!/usr/bin/env bash\n# deploy.sh - push the build')" ] + [ "$(tail -c 1 bin/deploy.sh | od -An -c | tr -d ' ')" = '\n' ] + [ "$(tail -n 1 bin/deploy.sh)" != "" ] + grep --quiet 'bin/deploy.sh' "$(git rev-parse --git-common-dir)/kit/context.log" +} + +@test "every file kit new file makes has the shape of its own template" { + "${kit_root}/bin/kit" new file bin/a.sh --purpose=a + "${kit_root}/bin/kit" new file lib/b.sh --variant=module --purpose=b + [ ! -x lib/b.sh ] + "${kit_root}/bin/kit" new file justfile + "${kit_root}/bin/kit" new file tools.just + grep --quiet --fixed-strings 'export tools_justfile := source_file()' tools.just + grep --quiet --fixed-strings '"${tools_justfile}"' tools.just + "${kit_root}/bin/kit" new file docs/guide.md --title=Guide + [ "$(cat docs/guide.md)" = "# Guide" ] + "${kit_root}/bin/kit" new file conf/app.yml --purpose=app + "${kit_root}/bin/kit" new file sub/.gitignore + "${kit_root}/bin/kit" new file .github/workflows/check.yml --purpose=gate --workflow=check + grep --quiet '^name: check$' .github/workflows/check.yml + run shape bin/a.sh lib/b.sh justfile tools.just docs/guide.md conf/app.yml sub/.gitignore \ + .github/workflows/check.yml + [ "$status" -eq 0 ] +} + +@test "a hole left empty, a path that exists or leaves the root, and an unknown rule or variant are refused" { + run "${kit_root}/bin/kit" new file docs/guide.md + [ "$status" -eq 2 ] + [[ "$output" == *"the document template of the markdown rule needs --title=<…>"* ]] + [ ! -e docs/guide.md ] + printf 'keep\n' > kept.sh + run "${kit_root}/bin/kit" new file kept.sh --purpose=x + [ "$status" -eq 1 ] + [ "$(cat kept.sh)" = "keep" ] + run "${kit_root}/bin/kit" new file ../out.sh --purpose=x + [ "$status" -eq 2 ] + run "${kit_root}/bin/kit" new file data.json + [ "$status" -eq 2 ] + [[ "$output" == *"no rule with a template governs data.json"* ]] + run "${kit_root}/bin/kit" new file x.sh --variant=nope --purpose=x + [[ "$output" == *"its templates are: script module"* ]] + run "${kit_root}/bin/kit" new file x --rule=nope + [[ "$output" == *"there is no rule nope; the rules are: bash gitignore json just markdown workflows yaml"* ]] + run "${kit_root}/bin/kit" new file bin/tool --rule=bash --purpose="no extension" + [ "$status" -eq 0 ] + [ "$(head -n 1 bin/tool)" = "#!/usr/bin/env bash" ] +} + +@test "the most specific rule's template is chosen, and a path-limited variant is the default only on its path" { + run in_template template:rules .github/workflows/check.yml + [ "${lines[0]}" = ".kit/rules/workflows.md" ] + [ "${lines[1]}" = ".kit/rules/yaml.md" ] + run in_template template:applies .kit/rules/just.md justfile + [ "$(cut -d $'\x1f' -f 1 <<<"$output" | paste -s -d ' ' -)" = "root module" ] + run in_template template:applies .kit/rules/just.md tools.just + [ "$(cut -d $'\x1f' -f 1 <<<"$output")" = "module" ] +} + +@test "a file off its template's shape is named with the line it leaves, and a hole named twice must agree" { + "${kit_root}/bin/kit" new file bin/a.sh --purpose=a + sed -i.bak '/^set -o/d' bin/a.sh + run shape bin/a.sh + [ "$status" -ne 0 ] + [[ "$output" == *"bin/a.sh does not have the shape of the bash template (script, module)"* ]] + [[ "$output" == *".kit/templates/bash/script line 5: set -o errexit -o nounset -o pipefail"* ]] + "${kit_root}/bin/kit" new file tools.just + sed -i.bak 's/"${tools_justfile}"/"${other_justfile}"/' tools.just + run shape tools.just + [ "$status" -ne 0 ] +} + +@test "a hole that is a line matches any run of lines, none included, and one within a line any text on it" { + printf 'head «title» tail\n«body»\nend\n' > "${BATS_TEST_TMPDIR}/t" + printf 'head anything at all tail\nend\n' > none + printf 'head x tail\none\ntwo\nend\n' > two + printf 'head x tail\nend\nmore\n' > after + in_template template:matches "${BATS_TEST_TMPDIR}/t" none + in_template template:matches "${BATS_TEST_TMPDIR}/t" two + ! in_template template:matches "${BATS_TEST_TMPDIR}/t" after + printf 'a.b «x»\n' > "${BATS_TEST_TMPDIR}/dot" + printf 'aXb y\n' > dot + ! in_template template:matches "${BATS_TEST_TMPDIR}/dot" dot +} + +@test "kit lint holds a governed file to its shape, and skips templates, regions, and generated paths" { + printf '#!/usr/bin/env bash\necho hi\n' > loose.sh + mkdir -p locks + printf 'lockfileVersion: 9\n' > locks/a.yaml + printf '/locks/** linguist-generated\n' > .gitattributes + run "${kit_root}/bin/kit" lint loose.sh locks/a.yaml .kit/templates/bash/script + [[ "$output" == *"loose.sh does not have the shape of the bash template"* ]] + [[ "$output" != *"locks/a.yaml does not"* ]] + [[ "$output" != *"templates/bash/script does not"* ]] + [[ "$output" == *".kit/rules/yaml.md: the shape of its templates over 0 path(s)"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/tidy.bats b/tests/tidy.bats new file mode 100644 index 0000000..01286db --- /dev/null +++ b/tests/tidy.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats +# tidy.bats - the one decision in tidy that needs no GitHub: a clean detached worktree is residue only when the +# default branch already has its commit +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" + work=$(mktemp -d) + cd "${work}" + git init --quiet --bare --initial-branch=main origin.git + git clone --quiet origin.git desk 2>/dev/null + cd desk + git config user.name t && git config user.email t@example.com && git config commit.gpgsign false + printf 'one\n' > a && git add a && git commit --quiet --message one && git push --quiet origin main + git remote set-head origin main + mkdir worktrees +} + +teardown() { + rm -rf "${work}" +} + +# decide <path> runs tidy's decision on <path>, a detached checkout, with main as the default branch and no dry +# run. +decide() { + bash -c 'source "${kit_root}/bin/1st.sh" && use git \ + && git:tidy-worktree "$1" "" true main "$(git rev-parse --show-toplevel)" false' _ "$1" +} + +# fields prints every linked worktree as "<dir> <detached> [<branch>]", read the way the verbs read git:worktrees. +fields() { + bash -c 'source "${kit_root}/bin/1st.sh" && use git && git:worktrees' \ + | while IFS=$'\t' read -r path detached branch + do + printf '%s %s [%s]\n' "${path##*/}" "${detached}" "${branch}" + done +} + +@test "the worktree list keeps a detached checkout's empty branch field where a tab read can keep it" { + git worktree add --quiet --detach worktrees/loose HEAD + git worktree add --quiet -b topic worktrees/topic HEAD + run fields + [ "$status" -eq 0 ] + [[ "$output" == *"loose true []"* ]] + [[ "$output" == *"topic false [topic]"* ]] +} + +@test "a clean detached worktree at a commit on main is removed" { + git worktree add --quiet --detach worktrees/old HEAD + run decide "${PWD}/worktrees/old" + [ "$status" -eq 0 ] + [[ "$output" == *"removing"* ]] + [ ! -e worktrees/old ] +} + +@test "a clean detached worktree at a commit main does not reach is kept" { + git worktree add --quiet --detach worktrees/only HEAD + printf 'two\n' > worktrees/only/b && git -C worktrees/only add b && git -C worktrees/only commit --quiet --message two + run decide "${PWD}/worktrees/only" + [ "$status" -eq 1 ] + [[ "$output" == *"keeping"* ]] + [ -e worktrees/only ] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/toolchain.bats b/tests/toolchain.bats new file mode 100644 index 0000000..fecadfc --- /dev/null +++ b/tests/toolchain.bats @@ -0,0 +1,147 @@ +#!/usr/bin/env bats +# toolchain.bats - mise, brew, and docker each answer the toolchain seam, through a fake of their tool +# ---------------------------------------------------------------------------------------------------------------------- +# +# Each provider is driven by tests/fakes/fake-tool standing in for its tool, so what is proven is how the provider +# reads its tool's answers and what it asks of it. What only the real tool can prove, that it answers as the +# fixtures do, is proven by running kit toolchain on a desk that has it. + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + export FAKE_DIR="${BATS_TEST_TMPDIR}/fake" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${FAKE_DIR}/bin" + for tool in mise brew docker + do + ln -s "${BATS_TEST_DIRNAME}/fakes/fake-tool" "${FAKE_DIR}/bin/${tool}" + : > "${FAKE_DIR}/${tool}.routes" + done + export PATH="${FAKE_DIR}/bin:${PATH}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main +} + +teardown() { + rm -rf "${repo}" +} + +fixture() { cat > "${FAKE_DIR}/$1"; } +route() { printf '%s\t%s\n' "$2" "$3" >> "${FAKE_DIR}/$1.routes"; } + +@test "the toolchains are detected from their files, in order, and a declared one wins" { + run "${kit_root}/bin/kit" toolchain list + [ -z "$output" ] + run "${kit_root}/bin/kit" toolchain install + [ "$status" -eq 0 ] + [[ "$output" == *"no toolchain is declared or detected"* ]] + touch mise.toml Brewfile toolchain.Dockerfile + run "${kit_root}/bin/kit" toolchain list + [ "$(awk '{ print $1 "=" $2 }' <<<"$output" | paste -s -d ' ' -)" = "mise=mise.toml brew=Brewfile docker=toolchain.Dockerfile" ] + mkdir .kit && printf 'toolchains:\n - provider: brew\n file: tools/Brewfile\n' > .kit/settings.yml + run "${kit_root}/bin/kit" toolchain list + [ "$output" = "brew tools/Brewfile" ] + printf 'toolchains:\n - provider: nix\n' > .kit/settings.yml + run "${kit_root}/bin/kit" toolchain list + [ "$status" -ne 0 ] + [[ "$output" == *"'nix' is not a toolchain the kit knows; the toolchains are: brew docker mise"* ]] +} + +@test "mise installs, reports, updates, and puts its bin paths first" { + touch mise.toml + fixture outdated.json <<<'{"jq": {"requested": "1.8", "current": "1.8.0", "latest": "1.8.1"}}' + fixture paths.txt <<<$'/m/jq/bin\n/m/just/bin' + route mise 'trust*' '!0' + route mise 'install --quiet' '!0' + route mise 'outdated --json' outdated.json + route mise 'upgrade --quiet' '!0' + route mise 'bin-paths' paths.txt + run "${kit_root}/bin/kit" toolchain install + [ "$status" -eq 0 ] + [[ "$output" == *"mise: installing from mise.toml"* ]] + run "${kit_root}/bin/kit" toolchain outdated + [ "$status" -eq 0 ] + [[ "$output" == *"mise jq 1.8.0 → 1.8.1"* ]] + run "${kit_root}/bin/kit" toolchain outdated --check + [ "$status" -eq 1 ] + run "${kit_root}/bin/kit" toolchain update + [ "$status" -eq 0 ] + grep --quiet --line-regexp 'upgrade --quiet' "${FAKE_DIR}/mise.calls" + run "${kit_root}/bin/kit" toolchain path + [ "$output" = $'/m/jq/bin\n/m/just/bin' ] + run "${kit_root}/bin/kit" toolchain run bash -c 'printf "%s" "${PATH%%:*}"' + [ "$output" = "/m/jq/bin" ] +} + +@test "brew installs from the Brewfile without upgrading, and reports and moves only the Brewfile's tools" { + touch Brewfile + fixture names.txt <<<$'jq\nshellcheck' + fixture outdated.json <<'JSON' +{"formulae": [{"name": "jq", "installed_versions": ["1.7.1"], "current_version": "1.8.1"}, + {"name": "wget", "installed_versions": ["1.24"], "current_version": "1.25"}], + "casks": []} +JSON + fixture prefix.txt <<<'/opt/brew' + route brew 'bundle install --file Brewfile --no-upgrade --quiet' '!0' + route brew 'bundle list --file Brewfile --formula --cask' names.txt + route brew 'outdated --json=v2' outdated.json + route brew 'upgrade jq' '!0' + route brew '--prefix' prefix.txt + run "${kit_root}/bin/kit" toolchain install + [ "$status" -eq 0 ] + run "${kit_root}/bin/kit" toolchain outdated + [[ "$output" == *"brew jq 1.7.1 → 1.8.1"* ]] + [[ "$output" != *"wget"* ]] + run "${kit_root}/bin/kit" toolchain update + [ "$status" -eq 0 ] + grep --quiet --line-regexp 'upgrade jq' "${FAKE_DIR}/brew.calls" + run "${kit_root}/bin/kit" toolchain path + [ "$output" = $'/opt/brew/bin\n/opt/brew/sbin' ] +} + +@test "docker builds its image once per file digest, runs a command in it, and pins its bases" { + printf 'FROM alpine:3.20@sha256:old AS tools\nRUN true\nFROM tools\nFROM debian:12\n' > toolchain.Dockerfile + fixture alpine.json <<<'{"digest": "sha256:new"}' + fixture debian.json <<<'{"digest": "sha256:deb"}' + route docker 'image inspect *' '!1' + route docker 'build --quiet --file toolchain.Dockerfile --tag kit-toolchain:* .' '!0' + route docker 'run --rm *' '!0' + route docker 'buildx imagetools inspect alpine:3.20 *' alpine.json + route docker 'buildx imagetools inspect debian:12 *' debian.json + run "${kit_root}/bin/kit" toolchain install + [ "$status" -eq 0 ] + tag=$(sed -n 's/^build --quiet --file toolchain.Dockerfile --tag \(kit-toolchain:[0-9a-f]*\) \.$/\1/p' "${FAKE_DIR}/docker.calls") + [ "${#tag}" -eq 26 ] + run "${kit_root}/bin/kit" toolchain run just ci + [ "$status" -eq 0 ] + grep --quiet -- "run --rm --volume $(pwd -P):/work --workdir /work --user $(id -u):$(id -g) --env HOME=/tmp ${tag} just ci" \ + "${FAKE_DIR}/docker.calls" + run "${kit_root}/bin/kit" toolchain outdated + [[ "$output" == *"alpine:3.20"*"sha256:old"*"→ sha256:new"* ]] + [[ "$output" == *"debian:12"*"unpinned"*"→ sha256:deb"* ]] + [[ "$output" != *"tools"*"→"* ]] + run "${kit_root}/bin/kit" toolchain update + [ "$status" -eq 0 ] + [ "$(cat toolchain.Dockerfile)" = "$(printf 'FROM alpine:3.20@sha256:new AS tools\nRUN true\nFROM tools\nFROM debian:12@sha256:deb')" ] +} + +@test "the gate runs inside the docker toolchain, and with mise's path first otherwise" { + mkdir .kit && printf 'gate: echo gated\ntoolchains:\n - provider: docker\n' > .kit/settings.yml + printf 'FROM alpine:3.20\n' > toolchain.Dockerfile + route docker 'image inspect *' '!0' + route docker 'run --rm *' '!0' + run "${kit_root}/bin/kit" gate + [ "$status" -eq 0 ] + grep --quiet -- ' bash -c echo gated$' "${FAKE_DIR}/docker.calls" + printf 'gate: echo "${PATH%%%%:*}"\n' > .kit/settings.yml + mv toolchain.Dockerfile elsewhere.Dockerfile + touch mise.toml + fixture paths.txt <<<'/m/tools/bin' + route mise 'bin-paths' paths.txt + run "${kit_root}/bin/kit" gate + [ "$status" -eq 0 ] + [[ "$output" == *"/m/tools/bin"* ]] +} + +# ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/tracker.bats b/tests/tracker.bats new file mode 100644 index 0000000..80d5bdf --- /dev/null +++ b/tests/tracker.bats @@ -0,0 +1,147 @@ +#!/usr/bin/env bats +# tracker.bats - a reference names its tracker, the forge's own or a declared one, and each answers the same calls +# ---------------------------------------------------------------------------------------------------------------------- + +setup() { + export kit_root NO_COLOR=1 + kit_root=$(cd "${BATS_TEST_DIRNAME}/.." && pwd) + export KIT_HOME="${BATS_TEST_TMPDIR}/home" XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + export FAKE_DIR="${BATS_TEST_TMPDIR}/fake" + mkdir -p "${KIT_HOME}" "${XDG_CACHE_HOME}" "${FAKE_DIR}/bin" + for tool in gh az + do + ln -s "${BATS_TEST_DIRNAME}/fakes/fake-tool" "${FAKE_DIR}/bin/${tool}" + : > "${FAKE_DIR}/${tool}.routes" + done + export PATH="${FAKE_DIR}/bin:${PATH}" + repo=$(mktemp -d) + cd "${repo}" + git init --quiet --initial-branch=main + git remote add origin https://github.com/empowerite/example.git + mkdir .kit + cat > .kit/settings.yml <<'YAML' +trackers: + - prefix: "AB#" + provider: ado + organization: https://dev.azure.com/example + project: Claims Team +YAML + unset GITHUB_EVENT_PATH +} + +teardown() { + rm -rf "${repo}" +} + +# in_tracker <command>… runs a command with the tracker seam loaded, as a verb would. +in_tracker() { + bash -c 'source "${kit_root}/bin/1st.sh" && use tracker && "$@"' _ "$@" +} + +fixture() { cat > "${FAKE_DIR}/$1"; } +route() { printf '%s\t%s\n' "$2" "$3" >> "${FAKE_DIR}/$1.routes"; } + +@test "a reference names its tracker by its prefix, a bare number is the forge's own, and an unknown one is refused" { + run in_tracker tracker:parse 7 + [ "$output" = "$(printf '#\t7')" ] + run in_tracker tracker:parse '#7' + [ "$output" = "$(printf '#\t7')" ] + run in_tracker tracker:parse 'AB#4521' + [ "$output" = "$(printf 'AB#\t4521')" ] + run in_tracker tracker:parse 'JIRA-12' + [ "$status" -eq 2 ] + [[ "$output" == *"'JIRA-12' is not an issue reference here; the trackers answer AB#<number> #<number>"* ]] + run in_tracker tracker:parse 'AB#x' + [ "$status" -eq 2 ] +} + +@test "a reference's key opens its branch name, and a branch name is read back to its reference" { + run in_tracker tracker:key 7 + [ "$output" = "7" ] + run in_tracker tracker:key 'AB#4521' + [ "$output" = "ab4521" ] + run in_tracker tracker:branch-ref 7-fix-the-thing + [ "$output" = "#7" ] + run in_tracker tracker:branch-ref ab4521-fix-the-thing + [ "$output" = "AB#4521" ] + run in_tracker tracker:branch-ref topic + [ -z "$output" ] + in_tracker tracker:is-forge '#7' + ! in_tracker tracker:is-forge 'AB#4521' +} + +@test "the body's closing keywords name references to the trackers the forge does not link by itself" { + body=$'Some text.\nCloses AB#4521\nfixes ab#9 and Resolves: AB#10\nCloses #7' + run in_tracker tracker:body-refs "${body}" + [ "$(paste -s -d ' ' - <<<"$output")" = "AB#4521 AB#9 AB#10" ] +} + +@test "an Azure DevOps work item answers the tracker's calls through az, its closing states read as CLOSED" { + fixture active.json <<'JSON' +{"id": 4521, "fields": {"System.Title": "Fix the claim form", "System.State": "Active", + "System.AssignedTo": {"uniqueName": "bob@example.com"}}} +JSON + fixture done.json <<<'{"id": 4522, "fields": {"System.Title": "Old", "System.State": "Done"}}' + fixture created.json <<<'{"id": 4600}' + fixture me.txt <<<'ada@example.com' + route az 'boards work-item show --id 4521 --org https://dev.azure.com/example*' active.json + route az 'boards work-item show --id 4522 *' done.json + route az 'boards work-item update --id 4521 --assigned-to ada@example.com *' '!0' + route az 'boards work-item create --title New work --type Task --project Claims Team *' created.json + route az 'account show*' me.txt + run in_tracker tracker:of 'AB#4521' title + [ "$output" = "Fix the claim form" ] + run in_tracker tracker:of 'AB#4521' state + [ "$output" = "OPEN" ] + run in_tracker tracker:of 'AB#4522' state + [ "$output" = "CLOSED" ] + run in_tracker tracker:of 'AB#4521' assignees + [ "$output" = "bob@example.com" ] + run in_tracker tracker:of 'AB#4521' assign ada@example.com + [ "$status" -eq 0 ] + run in_tracker tracker:at 'AB#' me + [ "$output" = "ada@example.com" ] + run in_tracker tracker:at 'AB#' create "New work" + [ "$output" = "4600" ] + run in_tracker tracker:of 'AB#4521' url + [ "$output" = "https://dev.azure.com/example/Claims%20Team/_workitems/edit/4521" ] +} + +@test "a tracker record without its organization is refused with the setting to add" { + printf 'trackers:\n - prefix: "AB#"\n provider: ado\n' > .kit/settings.yml + run in_tracker tracker:of 'AB#4521' title + [ "$status" -ne 0 ] + [[ "$output" == *"add organization: <https://dev.azure.com/name> to the AB# tracker in .kit/settings.yml"* ]] +} + +@test "kit linked takes a work item a closing keyword names in the body, as it takes the forge's own issues" { + export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/summary.md" + fixture repo.json <<<'{"nameWithOwner": "empowerite/example", "url": "https://github.com/empowerite/example"}' + fixture no-closing.json <<<'{"data": {"repository": {"pullRequest": {"closingIssuesReferences": {"nodes": []}}}}}' + fixture body.json <<<'{"body": "Does the work.\nCloses AB#4521"}' + fixture active.json <<<'{"id": 4521, "fields": {"System.Title": "Fix it", "System.State": "Active"}}' + route gh 'repo view*' repo.json + route gh 'api graphql*number=3*' no-closing.json + route gh 'pr view 3 --json body*' body.json + route az 'boards work-item show --id 4521 *' active.json + run "${kit_root}/bin/kit" linked 3 + [ "$status" -eq 0 ] + [[ "$output" == *"pull request #3 is linked to AB#4521"* ]] + fixture body.json <<<'{"body": "Does the work."}' + run "${kit_root}/bin/kit" linked 3 + [ "$status" -eq 1 ] + [[ "$output" == *"pull request #3 is not linked to any issue"* ]] + grep --quiet 'Closes <reference>' "${GITHUB_STEP_SUMMARY}" +} + +@test "the settings take a tracker with a prefix and a provider, and refuse one without" { + "${kit_root}/bin/kit" install >/dev/null 2>&1 + run "${kit_root}/bin/kit" check + [ "$status" -eq 0 ] + printf 'trackers:\n - prefix: "A B#"\n provider: ado\n' > .kit/settings.yml + run "${kit_root}/bin/kit" check + [ "$status" -eq 1 ] + [[ "$output" == *"a tracker needs a prefix, letters and an optional #, and a provider (prefix 'A B#')"* ]] +} + +# ----------------------------------------------------------------------------------------------------------------------