From 1bb76975764e0608d8b1cfc001cd8c64c5951a28 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:12:20 +0900 Subject: [PATCH 1/8] docs: design project health improvements --- ...7-25-project-health-improvements-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-project-health-improvements-design.md diff --git a/docs/superpowers/specs/2026-07-25-project-health-improvements-design.md b/docs/superpowers/specs/2026-07-25-project-health-improvements-design.md new file mode 100644 index 0000000..38cc262 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-project-health-improvements-design.md @@ -0,0 +1,162 @@ +# Project Health Improvements Design + +## Goal + +Close every gap found in the 2026-07-25 project health review without changing +the user-facing `tab` workflow. + +The finished project will: + +- apply only the newest tmux session refresh result; +- cancel in-flight tmux list and create commands when the UI exits; +- exercise the real `tab` binary in an end-to-end smoke test; +- document and enforce a 450-line limit for hand-written source files; +- run dedicated Go vulnerability, workflow, and shell checks in CI; +- use Dependency Review Action v5 without the Node.js 20 warning; and +- protect `main` with a repository ruleset after the changes are merged. + +## Scope + +### Included source files + +The 450-line policy applies to tracked, hand-written files with these +extensions: + +- `.go` +- `.sh` +- `.ps1` +- `.yml` +- `.yaml` + +The limit includes tests, installers, issue forms, release configuration, and +workflows. Documentation, generated output, vendored code, and binary assets +are outside the rule. + +### Compatibility + +The implementation keeps the existing Go 1.25 minimum and does not change the +CLI syntax, key bindings, release matrix, or supported platforms. + +## Design + +### Refresh ordering + +`Model` owns a monotonically increasing load generation. The initial request +uses generation 1. Each refresh increments it and includes the generation in +the asynchronous command result. + +`Update` ignores a session result whose generation does not match the current +generation. An old success or failure therefore cannot replace a newer list, +clear its loading state, or surface a stale error. + +This keeps refresh responsive instead of disabling the `r` key while a request +is running. + +### Command cancellation + +The model receives a context when it is constructed. `loadSessions` and +`createSession` pass that context to the tmux client instead of using +`context.Background()`. + +The public `New` constructor retains its current signature and uses a +background context for direct model construction and tests. `Run` creates a +cancellable context, builds the production model with it, and cancels the +context when the Bubble Tea program returns. + +This confines the context to the UI lifetime without changing the public API. + +### Binary smoke test + +A new POSIX shell smoke test builds `cmd/tab`, places a deterministic fake +`tmux` executable first on `PATH`, and runs the real binary twice: + +1. outside tmux, where `tab alpha` must invoke `attach-session -t alpha`; +2. inside tmux, where it must invoke `switch-client -t alpha`. + +The fake executable also answers `tmux -V`, so the path covers `main`, +`app.Run`, `ExecClient.Check`, and `ExecClient.Connect` without taking over the +developer's terminal. + +The existing real-tmux smoke test remains in place because it checks session +creation and output parsing against the installed tmux version. + +### Source line enforcement + +`scripts/check-source-lines.sh` inspects tracked files in the policy scope and +fails with each violating path and line count. It accepts an optional repository +path so its own test can create a temporary fixture repository and verify both +the 450-line pass boundary and the 451-line failure boundary. + +The script runs locally through the documented contributor checklist and in a +dedicated Ubuntu quality job. + +### Dedicated quality checks + +The new CI quality job runs: + +- `scripts/check-source-lines.sh`; +- `govulncheck` from `golang.org/x/vuln` v1.6.0; +- `actionlint` v1.7.12; and +- the runner-provided `shellcheck` over installers and scripts. + +Tool versions are explicit where Go installs are used. Dependabot continues to +track Go modules and GitHub Actions. + +### Dependency Review upgrade + +Dependency Review Action is updated from v4 to the immutable commit for +v5.0.0: + +`a1d282b36b6f3519aa1f3fc636f609c47dddb294` + +The comment records `v5.0.0`. This removes the Node.js 20 runtime warning and +preserves the repository's action-pinning posture. + +### Main branch ruleset + +After the pull request is merged, an active repository branch ruleset targets +the default branch and: + +- blocks branch deletion and non-fast-forward updates; +- requires changes to arrive through a pull request; +- requires the stable CI and security checks added by this change; and +- leaves repository administrators an explicit emergency bypass. + +The exact required check names are read from the successful pull-request run +before the ruleset is created. This avoids installing a rule that refers to a +nonexistent status context. + +## Error handling + +- Stale session results are silently discarded because they are expected + asynchronous completions, not user-facing failures. +- A current refresh error keeps the existing list and remains visible. +- Context cancellation is reported by the existing error path only if the UI + is still active; cancellation after exit has no visible effect. +- Quality scripts print the offending file or tool output and return nonzero. +- The ruleset is created only after all required status contexts have completed + successfully. + +## Testing + +Behavior changes follow red-green-refactor: + +1. add a failing test proving an older refresh result cannot overwrite a newer + one; +2. add failing tests proving list and create commands receive and observe the + model context; +3. add boundary tests for the 450-line checker; +4. add the real-binary smoke test and first demonstrate that the previous smoke + suite did not cover it; +5. run formatting, module verification, vet, unit tests, race tests, builds, + installer tests, both smoke tests, source-line checks, `govulncheck`, + `actionlint`, and `shellcheck`; +6. verify the pull-request checks on Ubuntu, macOS, and Windows before merge. + +## Non-goals + +- No visual redesign or key-binding change. +- No new runtime dependency. +- No broad package refactor. +- No requirement for contributor approvals in a single-maintainer repository. +- No automatic release. From fb791ba700f07d0762eb159ef9dcc6a19892f756 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:16:02 +0900 Subject: [PATCH 2/8] docs: plan project health improvements --- .../2026-07-25-project-health-improvements.md | 758 ++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-project-health-improvements.md diff --git a/docs/superpowers/plans/2026-07-25-project-health-improvements.md b/docs/superpowers/plans/2026-07-25-project-health-improvements.md new file mode 100644 index 0000000..e511432 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-project-health-improvements.md @@ -0,0 +1,758 @@ +# Project Health Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the code, test, CI, documentation, and repository-governance gaps found in the 2026-07-25 health review. + +**Architecture:** Keep the existing package boundaries. Add request generations and a UI-lifetime context inside `internal/ui`, add focused POSIX test scripts for source length and the compiled CLI, and collect dedicated static checks in one Ubuntu CI job. Apply the GitHub ruleset only after every new pull-request check succeeds. + +**Tech Stack:** Go 1.25, Bubble Tea v2, POSIX shell, GitHub Actions, `govulncheck` v1.6.0, `actionlint` v1.7.12, ShellCheck, GitHub repository rulesets. + +--- + +## File map + +- Modify `internal/ui/model.go`: load generations and context-aware commands. +- Modify `internal/ui/model_test.go`: stale-result and cancellation regression tests. +- Modify `internal/ui/run.go`: bind the command context to the Bubble Tea program lifetime. +- Create `scripts/check-source-lines.sh`: enforce the source-length policy. +- Create `scripts/test-source-lines.sh`: test the 450/451 boundaries. +- Create `scripts/tab-binary-smoke.sh`: exercise the compiled CLI with a fake tmux executable. +- Modify `CONTRIBUTING.md`: document policy and local checks. +- Modify `.github/workflows/ci.yml`: run policy, vulnerability, workflow, shell, and binary checks. +- Modify `.github/workflows/dependency-review.yml`: pin Dependency Review v5.0.0. +- Create GitHub repository ruleset after merge: protect `main`. + +### Task 1: Reject stale refresh results + +**Files:** +- Modify: `internal/ui/model_test.go` +- Modify: `internal/ui/model.go` + +- [ ] **Step 1: Write the failing stale-result test** + +Add a test that executes two refresh commands in request order but delivers +their messages in reverse order: + +```go +func TestOlderRefreshResultCannotReplaceNewerResult(t *testing.T) { + client := &fakeClient{lists: [][]tmux.Session{ + {session("initial", 1, 0)}, + {session("older", 1, 0)}, + {session("newer", 1, 0)}, + }} + model := loadModel(t, client) + + model, olderCmd := updateModel(t, model, keyMsg('r', "r")) + model, newerCmd := updateModel(t, model, keyMsg('r', "r")) + olderMsg := olderCmd() + newerMsg := newerCmd() + + model, _ = updateModel(t, model, newerMsg) + model, _ = updateModel(t, model, olderMsg) + + item := model.list.Items()[0].(sessionItem) + if item.Title() != "newer" || model.loading { + t.Fatalf("title = %q, loading = %v", item.Title(), model.loading) + } +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```sh +go test ./internal/ui -run TestOlderRefreshResultCannotReplaceNewerResult -count=1 +``` + +Expected: FAIL because the older result replaces `newer`. + +- [ ] **Step 3: Add generation metadata** + +Change the result and model state: + +```go +type sessionsLoadedMsg struct { + generation uint64 + sessions []tmux.Session + err error +} +``` + +Add `loadGeneration uint64` after `loading bool` in `Model`. Initialize +generation 1, pass it from `Init`, and increment it for every `r` request: + +```go +loadGeneration: 1 + +func (m Model) Init() tea.Cmd { + return loadSessions(m.client, m.loadGeneration) +} + +m.loadGeneration++ +return m, loadSessions(m.client, m.loadGeneration) +``` + +Ignore mismatched messages before changing loading, errors, or items: + +```go +case sessionsLoadedMsg: + if msg.generation != m.loadGeneration { + return m, nil + } + m.loading = false +``` + +Return the generation from the command: + +```go +func loadSessions(client tmux.Client, generation uint64) tea.Cmd { + return func() tea.Msg { + sessions, err := client.List(context.Background()) + return sessionsLoadedMsg{ + generation: generation, + sessions: sessions, + err: err, + } + } +} +``` + +- [ ] **Step 4: Run focused and package tests** + +Run: + +```sh +go test ./internal/ui -run 'TestOlderRefreshResultCannotReplaceNewerResult|TestRefresh' -count=1 +go test ./internal/ui -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "fix: ignore stale session refreshes" +``` + +### Task 2: Cancel tmux work when the UI exits + +**Files:** +- Modify: `internal/ui/model_test.go` +- Modify: `internal/ui/model.go` +- Modify: `internal/ui/run.go` + +- [ ] **Step 1: Extend the fake client and write failing tests** + +Add these fields to `fakeClient`: + +```go +listContext context.Context +createContext context.Context +``` + +Assign the context at the start of each existing fake method: + +```go +func (f *fakeClient) List(ctx context.Context) ([]tmux.Session, error) { + f.listContext = ctx + index := f.listCalls + f.listCalls++ + if index < len(f.listErrors) && f.listErrors[index] != nil { + return nil, f.listErrors[index] + } + if len(f.lists) == 0 { + return []tmux.Session{}, nil + } + if index >= len(f.lists) { + index = len(f.lists) - 1 + } + return f.lists[index], nil +} + +func (f *fakeClient) Create(ctx context.Context, name, dir string) error { + f.createContext = ctx + f.createName = name + f.createDir = dir + return f.createError +} +``` + +Add focused tests: + +```go +func TestLoadUsesModelContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := &fakeClient{} + model := newModel(ctx, client, "/tmp/project") + cancel() + + model.Init()() + + if !errors.Is(client.listContext.Err(), context.Canceled) { + t.Fatalf("context error = %v", client.listContext.Err()) + } +} + +func TestCreateUsesModelContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := &fakeClient{} + model := newModel(ctx, client, "/tmp/project") + model = loadModelFromModel(t, model) + model, _ = updateModel(t, model, keyMsg('n', "n")) + model.input.SetValue("work") + model, cmd := updateModel(t, model, keyMsg(tea.KeyEnter, "")) + cancel() + + cmd() + + if !errors.Is(client.createContext.Err(), context.Canceled) { + t.Fatalf("context error = %v", client.createContext.Err()) + } +} +``` + +Use a helper that runs `Init` on a supplied model so the context-aware model is +not replaced by `New`: + +```go +func loadModelFromModel(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + if cmd == nil { + t.Fatal("Init returned nil command") + } + model, _ = updateModel(t, model, cmd()) + return model +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```sh +go test ./internal/ui -run 'TestLoadUsesModelContext|TestCreateUsesModelContext' -count=1 +``` + +Expected: compilation failure because `newModel` and the model context do not +exist. + +- [ ] **Step 3: Add the model context** + +Add `ctx context.Context` to `Model`. Preserve the public constructor: + +```go +func New(client tmux.Client, cwd string) Model { + return newModel(context.Background(), client, cwd) +} + +func newModel(ctx context.Context, client tmux.Client, cwd string) Model { + keys := newKeyMap() + delegate := list.NewDefaultDelegate() + sessionList := list.New(nil, delegate, 80, 24) + sessionList.Title = "tmux sessions" + sessionList.SetStatusBarItemName("session", "sessions") + sessionList.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{keys.newSession, keys.refresh} + } + sessionList.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{keys.newSession, keys.refresh} + } + + input := textinput.New() + input.Prompt = "session name: " + input.Placeholder = "project" + input.CharLimit = 100 + input.Validate = tmux.ValidateSessionName + input.SetWidth(48) + + return Model{ + ctx: ctx, + client: client, + cwd: cwd, + list: sessionList, + input: input, + keys: keys, + mode: modeList, + loading: true, + loadGeneration: 1, + } +} +``` + +Change `Init` and the refresh path to call +`loadSessions(m.ctx, m.client, m.loadGeneration)`. Change both factories to: + +```go +func loadSessions(ctx context.Context, client tmux.Client, generation uint64) tea.Cmd { + return func() tea.Msg { + sessions, err := client.List(ctx) + return sessionsLoadedMsg{ + generation: generation, + sessions: sessions, + err: err, + } + } +} + +func createSession(ctx context.Context, client tmux.Client, name, cwd string) tea.Cmd { + return func() tea.Msg { + err := client.Create(ctx, name, cwd) + return sessionCreatedMsg{name: name, err: err} + } +} +``` + +- [ ] **Step 4: Tie cancellation to `Run`** + +Update `internal/ui/run.go`: + +```go +func Run(client tmux.Client, cwd string) (string, bool, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + return runProgram(tea.NewProgram(newModel(ctx, client, cwd))) +} +``` + +- [ ] **Step 5: Run UI tests and race tests** + +Run: + +```sh +go test ./internal/ui -count=1 +go test -race ./internal/ui -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add internal/ui/model.go internal/ui/model_test.go internal/ui/run.go +git commit -m "fix: cancel tmux work with the UI" +``` + +### Task 3: Enforce the 450-line source policy + +**Files:** +- Create: `scripts/check-source-lines.sh` +- Create: `scripts/test-source-lines.sh` +- Modify: `CONTRIBUTING.md` + +- [ ] **Step 1: Write the checker boundary test first** + +Create `scripts/test-source-lines.sh` with a temporary git repository. Generate +450 lines, expect success, then append line 451 and expect failure: + +```sh +#!/bin/sh +set -eu + +project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +fixture=$(mktemp -d) +cleanup() { + rm -rf "$fixture" +} +trap cleanup EXIT INT TERM + +git -C "$fixture" init -q +awk 'BEGIN { for (i = 1; i <= 450; i++) print "# line" }' >"$fixture/boundary.sh" +git -C "$fixture" add boundary.sh + +sh "$project_root/scripts/check-source-lines.sh" "$fixture" +printf '%s\n' "# line 451" >>"$fixture/boundary.sh" + +if sh "$project_root/scripts/check-source-lines.sh" "$fixture"; then + printf '%s\n' "expected a 451-line file to fail" >&2 + exit 1 +fi + +printf '%s\n' "source line limit tests passed" +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```sh +sh scripts/test-source-lines.sh +``` + +Expected: FAIL because `scripts/check-source-lines.sh` does not exist. + +- [ ] **Step 3: Implement the checker** + +Create `scripts/check-source-lines.sh`: + +```sh +#!/bin/sh +set -eu + +max_lines=450 +repository=${1:-$(git rev-parse --show-toplevel)} +scratch=$(mktemp -d) +cleanup() { + rm -rf "$scratch" +} +trap cleanup EXIT INT TERM + +git -C "$repository" ls-files -- \ + '*.go' '*.sh' '*.ps1' '*.yml' '*.yaml' >"$scratch/files" + +status=0 +while IFS= read -r file; do + lines=$(awk 'END { print NR }' "$repository/$file") + if [ "$lines" -gt "$max_lines" ]; then + printf '%s: %s lines (maximum %s)\n' "$file" "$lines" "$max_lines" >&2 + status=1 + fi +done <"$scratch/files" + +exit "$status" +``` + +- [ ] **Step 4: Document scope and commands** + +Add to `CONTRIBUTING.md`: + +```markdown +Keep every tracked, hand-written `.go`, `.sh`, `.ps1`, `.yml`, and `.yaml` +file at or below 450 lines. This includes tests and workflow configuration. +Documentation, generated output, vendored code, and binary assets are excluded. +Split a file by responsibility before it exceeds the limit. +``` + +Add both source-line scripts to the local check block. + +- [ ] **Step 5: Run tests and policy check** + +Run: + +```sh +sh scripts/test-source-lines.sh +sh scripts/check-source-lines.sh +``` + +Expected: the fixture reports its intentional 451-line failure, the test ends +with `source line limit tests passed`, and the real repository returns zero. + +- [ ] **Step 6: Commit** + +```sh +git add CONTRIBUTING.md scripts/check-source-lines.sh scripts/test-source-lines.sh +git commit -m "ci: enforce source file line limit" +``` + +### Task 4: Exercise the real CLI binary + +**Files:** +- Create: `scripts/tab-binary-smoke.sh` +- Modify: `.github/workflows/ci.yml` +- Modify: `CONTRIBUTING.md` + +- [ ] **Step 1: Add the binary smoke script** + +Create a temporary fake `tmux`, build the real binary, and check both connection +modes: + +```sh +#!/bin/sh +set -eu + +project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +scratch=$(mktemp -d) +cleanup() { + rm -rf "$scratch" +} +trap cleanup EXIT INT TERM + +go build -o "$scratch/tab" "$project_root/cmd/tab" + +cat >"$scratch/tmux" <<'SCRIPT' +#!/bin/sh +set -eu +case ${1:-} in + -V) + printf '%s\n' "tmux 3.6" + ;; + attach-session|switch-client) + printf '%s\n' "$*" >>"$TAB_TMUX_LOG" + ;; + *) + printf 'unexpected tmux arguments: %s\n' "$*" >&2 + exit 1 + ;; +esac +SCRIPT +chmod +x "$scratch/tmux" + +: >"$scratch/tmux.log" +PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX= \ + "$scratch/tab" alpha +grep -Fx 'attach-session -t alpha' "$scratch/tmux.log" + +: >"$scratch/tmux.log" +PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX=inside \ + "$scratch/tab" alpha +grep -Fx 'switch-client -t alpha' "$scratch/tmux.log" + +printf '%s\n' "tab binary smoke test passed" +``` + +- [ ] **Step 2: Demonstrate and run the new coverage** + +Run: + +```sh +sh scripts/tab-binary-smoke.sh +``` + +Expected: PASS with both exact tmux invocations printed. + +- [ ] **Step 3: Wire it into local and CI checks** + +Add `sh scripts/tab-binary-smoke.sh` to `CONTRIBUTING.md` and to the Ubuntu +integration job after installer tests. + +- [ ] **Step 4: Run all shell smoke tests** + +Run: + +```sh +sh scripts/test-install.sh +sh scripts/tmux-smoke.sh +sh scripts/tab-binary-smoke.sh +``` + +Expected: all three scripts print their success line. + +- [ ] **Step 5: Commit** + +```sh +git add scripts/tab-binary-smoke.sh CONTRIBUTING.md .github/workflows/ci.yml +git commit -m "test: exercise the compiled tab binary" +``` + +### Task 5: Add dedicated CI quality checks and upgrade Dependency Review + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/dependency-review.yml` +- Modify: `CONTRIBUTING.md` + +- [ ] **Step 1: Add the Ubuntu quality job** + +Add a `quality` job with these exact steps: + +```yaml + quality: + name: Quality (Ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Verify source file line limit + run: sh scripts/check-source-lines.sh + - name: Test source file line checker + run: sh scripts/test-source-lines.sh + - name: Check Go vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + - name: Check shell scripts + run: shellcheck install.sh scripts/*.sh + - name: Check GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +``` + +- [ ] **Step 2: Upgrade and pin Dependency Review** + +Replace v4 with: + +```yaml +- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 +``` + +- [ ] **Step 3: Document the extra local checks** + +Add these commands to the contributor checklist: + +```sh +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +shellcheck install.sh scripts/*.sh +``` + +- [ ] **Step 4: Run the quality suite** + +Run: + +```sh +sh scripts/check-source-lines.sh +sh scripts/test-source-lines.sh +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +shellcheck install.sh scripts/*.sh +``` + +Expected: all commands return zero and `govulncheck` reports no reachable +vulnerabilities. + +- [ ] **Step 5: Commit** + +```sh +git add .github/workflows/ci.yml .github/workflows/dependency-review.yml CONTRIBUTING.md +git commit -m "ci: expand project quality checks" +``` + +### Task 6: Verify, review, merge, and protect `main` + +**Files:** +- Verify all modified files. +- Update: `projects/tmux-attach-browser/PROJECT.md` in the parent workspace after + repository work completes. + +- [ ] **Step 1: Run the complete local verification suite** + +Run: + +```sh +test -z "$(gofmt -l .)" +git diff --check origin/main...HEAD +go mod verify +go vet ./... +go test -count=1 ./... +go test -race -count=1 ./... +go build ./... +sh scripts/check-source-lines.sh +sh scripts/test-source-lines.sh +sh scripts/test-install.sh +sh scripts/tmux-smoke.sh +sh scripts/tab-binary-smoke.sh +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +shellcheck install.sh scripts/*.sh +``` + +Expected: zero failures, formatting output empty, and no reachable +vulnerabilities. + +- [ ] **Step 2: Push and open a pull request** + +```sh +git push -u origin feat/project-health-improvements +gh pr create --base main --head feat/project-health-improvements \ + --title "Improve project health checks and runtime safety" \ + --body "$(printf '%s\n' \ + '## Summary' \ + '- prevent stale refresh results and cancel tmux work on UI exit' \ + '- enforce the 450-line source policy and exercise the compiled binary' \ + '- add vulnerability, workflow, and shell checks; upgrade Dependency Review v5' \ + '' \ + '## Verification' \ + '- complete local unit, race, build, smoke, and quality suites pass')" +``` + +The pull request body summarizes behavior, quality tooling, documentation, and +the planned post-merge ruleset. + +- [ ] **Step 3: Verify every pull-request check** + +Run: + +```sh +pr_number=$(gh pr view feat/project-health-improvements --json number --jq .number) +gh pr checks "$pr_number" --watch +``` + +Expected: CI on Ubuntu, macOS, and Windows, integration, quality, Dependency +Review, and CodeQL all complete successfully. + +- [ ] **Step 4: Merge the pull request** + +Use squash merge and delete the remote feature branch: + +```sh +pr_number=$(gh pr view feat/project-health-improvements --json number --jq .number) +gh pr merge "$pr_number" --squash --delete-branch +``` + +Then synchronize local `main` and rerun the core verification suite. + +- [ ] **Step 5: Create the active default-branch ruleset** + +Confirm the successful pull-request check names match the contexts below, then +create one active branch ruleset named `Protect main`: + +```sh +ruleset_file=$(mktemp) +trap 'rm -f "$ruleset_file"' EXIT INT TERM +printf '%s\n' '{ + "name": "Protect main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": true, + "required_status_checks": [ + {"context": "Test (ubuntu-latest)"}, + {"context": "Test (macos-latest)"}, + {"context": "Test (windows-latest)"}, + {"context": "Integration (Ubuntu)"}, + {"context": "Quality (Ubuntu)"}, + {"context": "Dependency Review"}, + {"context": "Analyze (go)"} + ], + "strict_required_status_checks_policy": true + } + } + ] +}' >"$ruleset_file" + +gh api --method POST repos/hmmhmmhm/tmux-attach-browser/rulesets \ + --input "$ruleset_file" +gh api repos/hmmhmmhm/tmux-attach-browser/rulesets \ + --jq '.[] | select(.name == "Protect main") | + {name, target, enforcement, bypass_actors, conditions, rules}' +``` + +Expected: one active branch ruleset targeting `~DEFAULT_BRANCH`, with the +administrator bypass and all four rule types. + +- [ ] **Step 6: Clean branches and update project memory** + +Verify only `main` remains locally and remotely. Add a dated fact and action log +entry to `projects/tmux-attach-browser/PROJECT.md` describing the merged PR, +successful checks, and ruleset. From 14ac268bab9493fec0ee763f6d3149fd6bfe458d Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:17:31 +0900 Subject: [PATCH 3/8] fix: ignore stale session refreshes --- internal/ui/model.go | 59 +++++++++++++++++++++++---------------- internal/ui/model_test.go | 22 +++++++++++++++ 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index d1d6e83..b8090cd 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -22,8 +22,9 @@ const ( ) type sessionsLoadedMsg struct { - sessions []tmux.Session - err error + generation uint64 + sessions []tmux.Session + err error } type sessionCreatedMsg struct { @@ -33,17 +34,18 @@ type sessionCreatedMsg struct { // Model is the complete terminal UI state. type Model struct { - client tmux.Client - cwd string - list list.Model - input textinput.Model - keys keyMap - mode viewMode - err error - loading bool - creating bool - chosen string - selected bool + client tmux.Client + cwd string + list list.Model + input textinput.Model + keys keyMap + mode viewMode + err error + loading bool + loadGeneration uint64 + creating bool + chosen string + selected bool } // New creates a session browser model. @@ -68,19 +70,20 @@ func New(client tmux.Client, cwd string) Model { input.SetWidth(48) return Model{ - client: client, - cwd: cwd, - list: sessionList, - input: input, - keys: keys, - mode: modeList, - loading: true, + client: client, + cwd: cwd, + list: sessionList, + input: input, + keys: keys, + mode: modeList, + loading: true, + loadGeneration: 1, } } // Init loads tmux sessions asynchronously. func (m Model) Init() tea.Cmd { - return loadSessions(m.client) + return loadSessions(m.client, m.loadGeneration) } // Result reports the session chosen by the user. @@ -92,6 +95,9 @@ func (m Model) Result() (string, bool) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case sessionsLoadedMsg: + if msg.generation != m.loadGeneration { + return m, nil + } m.loading = false if msg.err != nil { m.err = fmt.Errorf("load sessions: %w", msg.err) @@ -143,7 +149,8 @@ func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(keyMsg, m.keys.refresh): m.loading = true m.err = nil - return m, loadSessions(m.client) + m.loadGeneration++ + return m, loadSessions(m.client, m.loadGeneration) case keyMsg.Code == tea.KeyEnter: item, ok := m.list.SelectedItem().(sessionItem) @@ -232,10 +239,14 @@ var ( errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) ) -func loadSessions(client tmux.Client) tea.Cmd { +func loadSessions(client tmux.Client, generation uint64) tea.Cmd { return func() tea.Msg { sessions, err := client.List(context.Background()) - return sessionsLoadedMsg{sessions: sessions, err: err} + return sessionsLoadedMsg{ + generation: generation, + sessions: sessions, + err: err, + } } } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 69a9b52..1ee6465 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -147,6 +147,28 @@ func TestRefreshReplacesItems(t *testing.T) { } } +func TestOlderRefreshResultCannotReplaceNewerResult(t *testing.T) { + client := &fakeClient{lists: [][]tmux.Session{ + {session("initial", 1, 0)}, + {session("older", 1, 0)}, + {session("newer", 1, 0)}, + }} + model := loadModel(t, client) + + model, olderCmd := updateModel(t, model, keyMsg('r', "r")) + model, newerCmd := updateModel(t, model, keyMsg('r', "r")) + olderMsg := olderCmd() + newerMsg := newerCmd() + + model, _ = updateModel(t, model, newerMsg) + model, _ = updateModel(t, model, olderMsg) + + item := model.list.Items()[0].(sessionItem) + if item.Title() != "newer" || model.loading { + t.Fatalf("title = %q, loading = %v", item.Title(), model.loading) + } +} + func TestRefreshErrorKeepsExistingItems(t *testing.T) { client := &fakeClient{ lists: [][]tmux.Session{{session("work", 1, 0)}}, From 731133cc71167fbac625b7bba89be0e0a0ae7c93 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:19:55 +0900 Subject: [PATCH 4/8] fix: cancel tmux work with the UI --- internal/ui/model.go | 20 +++++++++----- internal/ui/model_test.go | 55 ++++++++++++++++++++++++++++++++------- internal/ui/run.go | 5 +++- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index b8090cd..5ad4d33 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -34,6 +34,7 @@ type sessionCreatedMsg struct { // Model is the complete terminal UI state. type Model struct { + ctx context.Context client tmux.Client cwd string list list.Model @@ -50,6 +51,10 @@ type Model struct { // New creates a session browser model. func New(client tmux.Client, cwd string) Model { + return newModel(context.Background(), client, cwd) +} + +func newModel(ctx context.Context, client tmux.Client, cwd string) Model { keys := newKeyMap() delegate := list.NewDefaultDelegate() sessionList := list.New(nil, delegate, 80, 24) @@ -70,6 +75,7 @@ func New(client tmux.Client, cwd string) Model { input.SetWidth(48) return Model{ + ctx: ctx, client: client, cwd: cwd, list: sessionList, @@ -83,7 +89,7 @@ func New(client tmux.Client, cwd string) Model { // Init loads tmux sessions asynchronously. func (m Model) Init() tea.Cmd { - return loadSessions(m.client, m.loadGeneration) + return loadSessions(m.ctx, m.client, m.loadGeneration) } // Result reports the session chosen by the user. @@ -150,7 +156,7 @@ func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = true m.err = nil m.loadGeneration++ - return m, loadSessions(m.client, m.loadGeneration) + return m, loadSessions(m.ctx, m.client, m.loadGeneration) case keyMsg.Code == tea.KeyEnter: item, ok := m.list.SelectedItem().(sessionItem) @@ -189,7 +195,7 @@ func (m Model) updateCreate(msg tea.Msg) (tea.Model, tea.Cmd) { } m.err = nil m.creating = true - return m, createSession(m.client, name, m.cwd) + return m, createSession(m.ctx, m.client, name, m.cwd) } } @@ -239,9 +245,9 @@ var ( errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) ) -func loadSessions(client tmux.Client, generation uint64) tea.Cmd { +func loadSessions(ctx context.Context, client tmux.Client, generation uint64) tea.Cmd { return func() tea.Msg { - sessions, err := client.List(context.Background()) + sessions, err := client.List(ctx) return sessionsLoadedMsg{ generation: generation, sessions: sessions, @@ -250,9 +256,9 @@ func loadSessions(client tmux.Client, generation uint64) tea.Cmd { } } -func createSession(client tmux.Client, name, cwd string) tea.Cmd { +func createSession(ctx context.Context, client tmux.Client, name, cwd string) tea.Cmd { return func() tea.Msg { - err := client.Create(context.Background(), name, cwd) + err := client.Create(ctx, name, cwd) return sessionCreatedMsg{name: name, err: err} } } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 1ee6465..013b63e 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -13,12 +13,14 @@ import ( ) type fakeClient struct { - lists [][]tmux.Session - listErrors []error - listCalls int - createName string - createDir string - createError error + lists [][]tmux.Session + listErrors []error + listCalls int + createName string + createDir string + createError error + listContext context.Context + createContext context.Context } type fakeProgram struct { @@ -30,7 +32,8 @@ func (f fakeProgram) Run() (tea.Model, error) { return f.model, f.err } -func (f *fakeClient) List(context.Context) ([]tmux.Session, error) { +func (f *fakeClient) List(ctx context.Context) ([]tmux.Session, error) { + f.listContext = ctx index := f.listCalls f.listCalls++ if index < len(f.listErrors) && f.listErrors[index] != nil { @@ -47,7 +50,8 @@ func (f *fakeClient) List(context.Context) ([]tmux.Session, error) { func (f *fakeClient) Check(context.Context) error { return nil } -func (f *fakeClient) Create(_ context.Context, name, dir string) error { +func (f *fakeClient) Create(ctx context.Context, name, dir string) error { + f.createContext = ctx f.createName = name f.createDir = dir return f.createError @@ -81,7 +85,11 @@ func updateModel(t *testing.T, model Model, msg tea.Msg) (Model, tea.Cmd) { func loadModel(t *testing.T, client *fakeClient) Model { t.Helper() - model := New(client, "/tmp/project") + return loadModelFromModel(t, New(client, "/tmp/project")) +} + +func loadModelFromModel(t *testing.T, model Model) Model { + t.Helper() cmd := model.Init() if cmd == nil { t.Fatal("Init returned nil command") @@ -90,6 +98,35 @@ func loadModel(t *testing.T, client *fakeClient) Model { return model } +func TestLoadUsesModelContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := &fakeClient{} + model := newModel(ctx, client, "/tmp/project") + cancel() + + model.Init()() + + if !errors.Is(client.listContext.Err(), context.Canceled) { + t.Fatalf("context error = %v", client.listContext.Err()) + } +} + +func TestCreateUsesModelContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := &fakeClient{} + model := loadModelFromModel(t, newModel(ctx, client, "/tmp/project")) + model, _ = updateModel(t, model, keyMsg('n', "n")) + model.input.SetValue("work") + model, cmd := updateModel(t, model, keyMsg(tea.KeyEnter, "")) + cancel() + + cmd() + + if !errors.Is(client.createContext.Err(), context.Canceled) { + t.Fatalf("context error = %v", client.createContext.Err()) + } +} + func TestInitLoadsSessions(t *testing.T) { client := &fakeClient{lists: [][]tmux.Session{{session("work", 2, 0)}}} diff --git a/internal/ui/run.go b/internal/ui/run.go index 2d90002..76e2529 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" tea "charm.land/bubbletea/v2" @@ -13,7 +14,9 @@ type programRunner interface { // Run starts the interactive browser and returns its selected session. func Run(client tmux.Client, cwd string) (string, bool, error) { - return runProgram(tea.NewProgram(New(client, cwd))) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + return runProgram(tea.NewProgram(newModel(ctx, client, cwd))) } func runProgram(program programRunner) (string, bool, error) { From af8449756cabdc32a9eac5617e3e5012f6cbfc58 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:20:28 +0900 Subject: [PATCH 5/8] ci: enforce source file line limit --- CONTRIBUTING.md | 4 ++++ scripts/check-source-lines.sh | 24 ++++++++++++++++++++++++ scripts/test-source-lines.sh | 23 +++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 scripts/check-source-lines.sh create mode 100644 scripts/test-source-lines.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5b70ab..f4fb3a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,8 @@ go test ./... Write a failing test before changing behavior. Keep tmux command execution in `internal/tmux`, Bubble Tea state in `internal/ui`, and CLI orchestration in `internal/app`. +Keep every tracked, hand-written `.go`, `.sh`, `.ps1`, `.yml`, and `.yaml` file at or below 450 lines. This includes tests and workflow configuration. Documentation, generated output, vendored code, and binary assets are excluded. Split a file by responsibility before it exceeds the limit. + Run all checks before opening a pull request: ```sh @@ -28,6 +30,8 @@ test -z "$(gofmt -l .)" go vet ./... go test -race ./... go build ./cmd/tab +sh scripts/check-source-lines.sh +sh scripts/test-source-lines.sh sh scripts/test-install.sh sh scripts/tmux-smoke.sh ``` diff --git a/scripts/check-source-lines.sh b/scripts/check-source-lines.sh new file mode 100644 index 0000000..9559b05 --- /dev/null +++ b/scripts/check-source-lines.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +max_lines=450 +repository=${1:-$(git rev-parse --show-toplevel)} +scratch=$(mktemp -d) +cleanup() { + rm -rf "$scratch" +} +trap cleanup EXIT INT TERM + +git -C "$repository" ls-files -- \ + '*.go' '*.sh' '*.ps1' '*.yml' '*.yaml' >"$scratch/files" + +status=0 +while IFS= read -r file; do + lines=$(awk 'END { print NR }' "$repository/$file") + if [ "$lines" -gt "$max_lines" ]; then + printf '%s: %s lines (maximum %s)\n' "$file" "$lines" "$max_lines" >&2 + status=1 + fi +done <"$scratch/files" + +exit "$status" diff --git a/scripts/test-source-lines.sh b/scripts/test-source-lines.sh new file mode 100644 index 0000000..01b5b87 --- /dev/null +++ b/scripts/test-source-lines.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +fixture=$(mktemp -d) +cleanup() { + rm -rf "$fixture" +} +trap cleanup EXIT INT TERM + +git -C "$fixture" init -q +awk 'BEGIN { for (i = 1; i <= 450; i++) print "# line" }' >"$fixture/boundary.sh" +git -C "$fixture" add boundary.sh + +sh "$project_root/scripts/check-source-lines.sh" "$fixture" +printf '%s\n' "# line 451" >>"$fixture/boundary.sh" + +if sh "$project_root/scripts/check-source-lines.sh" "$fixture"; then + printf '%s\n' "expected a 451-line file to fail" >&2 + exit 1 +fi + +printf '%s\n' "source line limit tests passed" From 5ce6484ea14fab2f10064dab1e2bf2db4801d70b Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:20:57 +0900 Subject: [PATCH 6/8] test: exercise the compiled tab binary --- .github/workflows/ci.yml | 2 ++ CONTRIBUTING.md | 1 + scripts/tab-binary-smoke.sh | 42 +++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 scripts/tab-binary-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5388197..f18b862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,5 +65,7 @@ jobs: run: go test -race ./... - name: Installer tests run: sh scripts/test-install.sh + - name: tab binary smoke test + run: sh scripts/tab-binary-smoke.sh - name: tmux smoke test run: sh scripts/tmux-smoke.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4fb3a8..90f9a3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ sh scripts/check-source-lines.sh sh scripts/test-source-lines.sh sh scripts/test-install.sh sh scripts/tmux-smoke.sh +sh scripts/tab-binary-smoke.sh ``` Pull requests should be small, explain user-visible behavior, and include tests. Do not include generated `dist` artifacts. diff --git a/scripts/tab-binary-smoke.sh b/scripts/tab-binary-smoke.sh new file mode 100644 index 0000000..cbe2001 --- /dev/null +++ b/scripts/tab-binary-smoke.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +scratch=$(mktemp -d) +cleanup() { + rm -rf "$scratch" +} +trap cleanup EXIT INT TERM + +cd "$project_root" +go build -o "$scratch/tab" ./cmd/tab + +cat >"$scratch/tmux" <<'SCRIPT' +#!/bin/sh +set -eu +case ${1:-} in + -V) + printf '%s\n' "tmux 3.6" + ;; + attach-session|switch-client) + printf '%s\n' "$*" >>"$TAB_TMUX_LOG" + ;; + *) + printf 'unexpected tmux arguments: %s\n' "$*" >&2 + exit 1 + ;; +esac +SCRIPT +chmod +x "$scratch/tmux" + +: >"$scratch/tmux.log" +PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX= \ + "$scratch/tab" alpha +grep -Fx 'attach-session -t alpha' "$scratch/tmux.log" + +: >"$scratch/tmux.log" +PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX=inside \ + "$scratch/tab" alpha +grep -Fx 'switch-client -t alpha' "$scratch/tmux.log" + +printf '%s\n' "tab binary smoke test passed" From 964f0d12f2c5c840f43aa948741cc0db311eb353 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:22:40 +0900 Subject: [PATCH 7/8] ci: expand project quality checks --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ .github/workflows/dependency-review.yml | 2 +- CONTRIBUTING.md | 3 +++ scripts/check-source-lines.sh | 5 +---- scripts/tab-binary-smoke.sh | 9 +++------ scripts/test-install.sh | 4 ++-- scripts/test-source-lines.sh | 7 ++----- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f18b862..b2f3b59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,26 @@ jobs: exit 1 } + quality: + name: Quality (Ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Verify source file line limit + run: sh scripts/check-source-lines.sh + - name: Test source file line checker + run: sh scripts/test-source-lines.sh + - name: Check Go vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + - name: Check shell scripts + run: shellcheck install.sh scripts/*.sh + - name: Check GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + integration: name: Integration (Ubuntu) runs-on: ubuntu-latest diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4487937..893ce8d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -12,4 +12,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/dependency-review-action@v4 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90f9a3c..615d97d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ sh scripts/test-source-lines.sh sh scripts/test-install.sh sh scripts/tmux-smoke.sh sh scripts/tab-binary-smoke.sh +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 +shellcheck install.sh scripts/*.sh ``` Pull requests should be small, explain user-visible behavior, and include tests. Do not include generated `dist` artifacts. diff --git a/scripts/check-source-lines.sh b/scripts/check-source-lines.sh index 9559b05..6e25e2c 100644 --- a/scripts/check-source-lines.sh +++ b/scripts/check-source-lines.sh @@ -4,10 +4,7 @@ set -eu max_lines=450 repository=${1:-$(git rev-parse --show-toplevel)} scratch=$(mktemp -d) -cleanup() { - rm -rf "$scratch" -} -trap cleanup EXIT INT TERM +trap 'rm -rf "$scratch"' EXIT INT TERM git -C "$repository" ls-files -- \ '*.go' '*.sh' '*.ps1' '*.yml' '*.yaml' >"$scratch/files" diff --git a/scripts/tab-binary-smoke.sh b/scripts/tab-binary-smoke.sh index cbe2001..9c3dc18 100644 --- a/scripts/tab-binary-smoke.sh +++ b/scripts/tab-binary-smoke.sh @@ -1,12 +1,9 @@ #!/bin/sh set -eu -project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +project_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) scratch=$(mktemp -d) -cleanup() { - rm -rf "$scratch" -} -trap cleanup EXIT INT TERM +trap 'rm -rf "$scratch"' EXIT INT TERM cd "$project_root" go build -o "$scratch/tab" ./cmd/tab @@ -30,7 +27,7 @@ SCRIPT chmod +x "$scratch/tmux" : >"$scratch/tmux.log" -PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX= \ +PATH="$scratch:$PATH" TAB_TMUX_LOG="$scratch/tmux.log" TMUX='' \ "$scratch/tab" alpha grep -Fx 'attach-session -t alpha' "$scratch/tmux.log" diff --git a/scripts/test-install.sh b/scripts/test-install.sh index db5d793..0451f52 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +project_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) test_root=$(mktemp -d "${TMPDIR:-/tmp}/tab-install-test.XXXXXX") trap 'rm -rf "$test_root"' EXIT INT TERM @@ -31,7 +31,7 @@ TAB_RELEASE_BASE_URL="file://$release_dir" \ TAB_ARCH=amd64 \ sh "$project_root/install.sh" >/dev/null -actual=$($test_root/bin/tab) +actual=$("$test_root/bin/tab") if [ "$actual" != "fixture tab v0.1.0" ]; then printf '%s\n' "FAIL: installed binary output was $actual" >&2 exit 1 diff --git a/scripts/test-source-lines.sh b/scripts/test-source-lines.sh index 01b5b87..45c47a1 100644 --- a/scripts/test-source-lines.sh +++ b/scripts/test-source-lines.sh @@ -1,12 +1,9 @@ #!/bin/sh set -eu -project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +project_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) fixture=$(mktemp -d) -cleanup() { - rm -rf "$fixture" -} -trap cleanup EXIT INT TERM +trap 'rm -rf "$fixture"' EXIT INT TERM git -C "$fixture" init -q awk 'BEGIN { for (i = 1; i <= 450; i++) print "# line" }' >"$fixture/boundary.sh" From 4e9aa526dcd24aece06898afaf0f11838f2dd9e4 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 12:24:40 +0900 Subject: [PATCH 8/8] test: exclude generated source from line checks --- CONTRIBUTING.md | 2 +- scripts/check-source-lines.sh | 8 +++++++- scripts/test-source-lines.sh | 11 ++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615d97d..239a8fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ go test ./... Write a failing test before changing behavior. Keep tmux command execution in `internal/tmux`, Bubble Tea state in `internal/ui`, and CLI orchestration in `internal/app`. -Keep every tracked, hand-written `.go`, `.sh`, `.ps1`, `.yml`, and `.yaml` file at or below 450 lines. This includes tests and workflow configuration. Documentation, generated output, vendored code, and binary assets are excluded. Split a file by responsibility before it exceeds the limit. +Keep every tracked, hand-written `.go`, `.sh`, `.ps1`, `.yml`, and `.yaml` file at or below 450 lines. This includes tests and workflow configuration. Documentation, `dist`, vendored code, Go files with the standard `Code generated ... DO NOT EDIT.` header, and binary assets are excluded. Split a file by responsibility before it exceeds the limit. Run all checks before opening a pull request: diff --git a/scripts/check-source-lines.sh b/scripts/check-source-lines.sh index 6e25e2c..a6afb8f 100644 --- a/scripts/check-source-lines.sh +++ b/scripts/check-source-lines.sh @@ -7,10 +7,16 @@ scratch=$(mktemp -d) trap 'rm -rf "$scratch"' EXIT INT TERM git -C "$repository" ls-files -- \ - '*.go' '*.sh' '*.ps1' '*.yml' '*.yaml' >"$scratch/files" + '*.go' '*.sh' '*.ps1' '*.yml' '*.yaml' \ + ':(exclude)vendor/**' ':(exclude)dist/**' >"$scratch/files" status=0 while IFS= read -r file; do + if [ "${file##*.}" = "go" ] && + sed -n '1,20p' "$repository/$file" | + grep -q '^// Code generated .* DO NOT EDIT\.$'; then + continue + fi lines=$(awk 'END { print NR }' "$repository/$file") if [ "$lines" -gt "$max_lines" ]; then printf '%s: %s lines (maximum %s)\n' "$file" "$lines" "$max_lines" >&2 diff --git a/scripts/test-source-lines.sh b/scripts/test-source-lines.sh index 45c47a1..c777c50 100644 --- a/scripts/test-source-lines.sh +++ b/scripts/test-source-lines.sh @@ -7,7 +7,16 @@ trap 'rm -rf "$fixture"' EXIT INT TERM git -C "$fixture" init -q awk 'BEGIN { for (i = 1; i <= 450; i++) print "# line" }' >"$fixture/boundary.sh" -git -C "$fixture" add boundary.sh +mkdir -p "$fixture/vendor/dependency" "$fixture/dist" +awk 'BEGIN { for (i = 1; i <= 451; i++) print "package dependency" }' \ + >"$fixture/vendor/dependency/large.go" +awk 'BEGIN { for (i = 1; i <= 451; i++) print "generated: true" }' \ + >"$fixture/dist/generated.yaml" +{ + printf '%s\n' "// Code generated by fixture. DO NOT EDIT." + awk 'BEGIN { for (i = 1; i <= 451; i++) print "package generated" }' +} >"$fixture/generated.go" +git -C "$fixture" add boundary.sh vendor dist generated.go sh "$project_root/scripts/check-source-lines.sh" "$fixture" printf '%s\n' "# line 451" >>"$fixture/boundary.sh"