From 11eca83d78f738359aee5b5aafb21eb3c1ba6cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Wed, 28 Oct 2026 14:08:25 +0100 Subject: [PATCH 1/2] Replace standalone quality tools with Bun native commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What does this PR do? Closes #239. Replace the standalone oxfmt and oxlint development dependencies with Bun 1.4.4 native commands: - `bun run format` runs `bun fmt .`; `bun run format:check` runs `bun fmt --check .`. - `bun run typecheck` runs `bun check`; `bun run lint` remains a compatibility alias. - Remove the old tool configurations and their platform bindings from the lockfile without upgrading unrelated dependencies. Keep the existing 100-column formatting in `bunfig.toml`; unchanged files require no formatting migration. - Pin all seven Bun setup steps across CI, CD and docs/browser workflows through `.bun-version`. Add native quality gates to releases and make docs audits react to runtime/dependency changes. Preserve the existing required CI status-check name. - Resolve errors exposed by the newly enabled strict checker: complete test fixtures, type-safe shared fetch mocks preserving Bun's `preconnect`, browser/Web API declarations, a discriminated `Row` type, boolean header visibility and a string annotation for keyboard input. No strictness relaxation or project-file exclusions. - Update README, contributor/installation docs, AGENTS.md, task instructions and repository skills. No repository custom agents exist to update. Historical release posts are unchanged. - Tell knip that `fmt` and `check` are native Bun subcommands, not missing external binaries. **Intentional coverage change:** `bun check` is a TypeScript checker, not a linter. The old oxlint correctness/suspicious rules are intentionally retired with the user's approval; this is not equivalent lint coverage. Vue templates remain outside native TypeScript checking and are processed by the documentation build. There is no CLI interface or compiled-binary runtime requirement change. ### How did you verify your code works? - `bun test --reporter=dots` — passed: 1,025 tests, 0 failures (baseline: 1,024 tests). Added a fetch-mock regression check. - `bun run typecheck` — passed: 55 TypeScript files, zero type errors. - `bun run lint` — passed: compatibility alias runs the same native type checker. - `bun run format:check` — passed: all 146 matched files formatted. - `bun run knip` — passed. - `bun install --frozen-lockfile --quiet` — passed without lockfile changes. - `bun run build.ts` — passed; standalone macOS arm64 binary built and ad-hoc signed. - `dist/github-code-search --version` — passed: `2.0.0` with the build's commit/platform metadata. - `bun run docs:build` — passed, no dead-link errors; existing bundle-size advisory remains. - `actionlint .github/workflows/*.yaml` — passed using actionlint 1.7.12 downloaded only into session artifacts (no project dependency added). - Migration assertions — passed: removed dependencies/bindings absent, native scripts exact, all seven Bun setup steps read `.bun-version`. - Negative fixtures — passed: an intentional TS2322 error and unformatted code each exit 1; `bun fmt --check` leaves input unchanged. Aikido scanning could not run because its MCP server is unavailable in this session. Setup: https://help.aikido.dev/ide-plugins/aikido-mcp. Browser accessibility/responsive suites are left to their updated PR workflows; no Vue, CSS or page-layout changes are included here. No release tag was created. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .bun-version | 1 + .../instructions/bug-fixing.instructions.md | 4 +- .../documentation.instructions.md | 2 +- .../github-actions.instructions.md | 6 + .../implement-feature.instructions.md | 4 +- .../instructions/refactoring.instructions.md | 4 +- .github/instructions/release.instructions.md | 4 +- .github/skills/bug-fixing.md | 4 +- .github/skills/documentation.md | 2 +- .github/skills/feature.md | 4 +- .github/skills/github-actions.md | 10 +- .github/skills/refactoring.md | 4 +- .github/skills/release.md | 4 +- .github/workflows/a11y.yaml | 5 +- .github/workflows/cd.yaml | 10 +- .github/workflows/ci.yaml | 19 +- .github/workflows/docs.yaml | 7 +- .github/workflows/lighthouse.yaml | 5 +- .github/workflows/responsive.yaml | 5 + .oxfmtrc.json | 4 - .oxlintrc.json | 57 ------ AGENTS.md | 37 ++-- CONTRIBUTING.md | 14 +- README.md | 7 +- build.ts | 2 +- bun.lock | 84 -------- bunfig.toml | 3 + docs/getting-started/index.md | 2 +- docs/getting-started/installation.md | 16 +- knip.json | 2 +- package.json | 12 +- scripts/responsive.pw.ts | 1 - src/api-utils.test.ts | 89 +++++---- src/api.test.ts | 175 +++++++++-------- src/cache.test.ts | 8 +- src/output.test.ts | 8 +- src/render.test.ts | 2 +- src/render/highlight.test.ts | 30 ++- src/render/mouse-hit.test.ts | 10 +- src/test-setup.ts | 7 + src/tui.ts | 4 +- src/types.ts | 30 +-- src/upgrade.test.ts | 179 ++++++++++-------- tsconfig.json | 2 +- 44 files changed, 452 insertions(+), 437 deletions(-) create mode 100644 .bun-version delete mode 100644 .oxfmtrc.json delete mode 100644 .oxlintrc.json diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000..1c99cf0 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.4.4 diff --git a/.github/instructions/bug-fixing.instructions.md b/.github/instructions/bug-fixing.instructions.md index 97be174..770b839 100644 --- a/.github/instructions/bug-fixing.instructions.md +++ b/.github/instructions/bug-fixing.instructions.md @@ -61,8 +61,8 @@ For bugs in `tui.ts` or `api.ts` (side-effectful code), a test may not be practi ```bash bun test # the previously failing test now passes; full suite still green -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no formatting diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no formatting diff bun run knip # no unused exports or imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/instructions/documentation.instructions.md b/.github/instructions/documentation.instructions.md index 8ec039e..f66a516 100644 --- a/.github/instructions/documentation.instructions.md +++ b/.github/instructions/documentation.instructions.md @@ -96,7 +96,7 @@ Before opening a PR for any docs change: ```bash bun run docs:build # must complete without errors or dead-link warnings -bun run format:check # oxfmt — no formatting diff +bun run format:check # bun fmt --check — no formatting diff ``` - All internal links must resolve (VitePress reports dead links on build). diff --git a/.github/instructions/github-actions.instructions.md b/.github/instructions/github-actions.instructions.md index cb46890..02a3d4f 100644 --- a/.github/instructions/github-actions.instructions.md +++ b/.github/instructions/github-actions.instructions.md @@ -49,6 +49,12 @@ Actions that appear in multiple workflows (e.g. `actions/checkout`, `oven-sh/set always reference **the same commit SHA** across all files. Use `grep` to find all usages before opening a PR. +Every `oven-sh/setup-bun` step must use `bun-version-file: .bun-version`, including +release, documentation and browser-test jobs. Keep `.bun-version` at Bun 1.4.4 or +newer: quality checks use native `bun fmt` / `bun check`, not standalone tools. +Use `bun run typecheck` and `bun run format:check` in CI; `bun run lint` is only a +compatibility alias for type checking, not an equivalent linter. + ```bash grep -r "oven-sh/setup-bun" .github/workflows/ ``` diff --git a/.github/instructions/implement-feature.instructions.md b/.github/instructions/implement-feature.instructions.md index e99daf7..742fc3f 100644 --- a/.github/instructions/implement-feature.instructions.md +++ b/.github/instructions/implement-feature.instructions.md @@ -58,8 +58,8 @@ Before opening the pull request, ensure every item passes: ```bash bun test # all tests green -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no formatting diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no formatting diff bun run knip # no unused exports or imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/instructions/refactoring.instructions.md b/.github/instructions/refactoring.instructions.md index efd5ef5..6abb61b 100644 --- a/.github/instructions/refactoring.instructions.md +++ b/.github/instructions/refactoring.instructions.md @@ -67,8 +67,8 @@ Remove dead code rather than leaving it commented out. ```bash bun test # full suite passes — same behaviour before and after -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no formatting diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no formatting diff bun run knip # no unused exports or imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md index 15c1760..08fa31b 100644 --- a/.github/instructions/release.instructions.md +++ b/.github/instructions/release.instructions.md @@ -104,8 +104,8 @@ Do **not** create the GitHub Release manually — the CD pipeline handles it. ```bash bun test # full suite green -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no diff bun run knip # no unused exports bun run build.ts # binary compiles ``` diff --git a/.github/skills/bug-fixing.md b/.github/skills/bug-fixing.md index d19bb48..91eb8a3 100644 --- a/.github/skills/bug-fixing.md +++ b/.github/skills/bug-fixing.md @@ -121,8 +121,8 @@ Document manual repro steps in the PR description: ```bash bun test # the previously failing test now passes; full suite still green -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no formatting diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no formatting diff bun run knip # no unused exports or imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/skills/documentation.md b/.github/skills/documentation.md index f2b3eb3..e6affb1 100644 --- a/.github/skills/documentation.md +++ b/.github/skills/documentation.md @@ -158,5 +158,5 @@ bun run docs:build:a11y # build for a11y audit bun run docs:preview -- --port 4173 & bun run docs:a11y # 0 pa11y violations bun run docs:test:responsive # 20/20 Playwright tests green -bun run format:check # oxfmt — no formatting diff +bun run format:check # bun fmt --check — no formatting diff ``` diff --git a/.github/skills/feature.md b/.github/skills/feature.md index a45840f..824d00d 100644 --- a/.github/skills/feature.md +++ b/.github/skills/feature.md @@ -121,8 +121,8 @@ Conventions: ```bash bun test # all tests green (including new ones) -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no diff bun run knip # no unused exports / imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/skills/github-actions.md b/.github/skills/github-actions.md index c58e188..0a083c6 100644 --- a/.github/skills/github-actions.md +++ b/.github/skills/github-actions.md @@ -13,7 +13,7 @@ repository. Read this before writing or modifying any `.github/workflows/*.yaml` start-preview-server/ ← composite reusable action (shared server start logic) action.yml workflows/ - ci.yaml ← unit tests, format, lint, knip + coverage PR comment + ci.yaml ← unit tests, native format/type checks, knip + coverage PR comment cd.yaml ← binary build + GitHub Release on tag push docs.yaml ← VitePress Pages deploy + versioned snapshot a11y.yaml ← WCAG 2.1 AA audit via pa11y-ci @@ -21,6 +21,14 @@ repository. Read this before writing or modifying any `.github/workflows/*.yaml` responsive.yaml ← Playwright mobile viewport checks ``` +All Bun setup steps use `bun-version-file: .bun-version` (Bun 1.4.4 or newer). +Update that single pin rather than using `latest` or per-workflow versions. +CI and release checks use `bun run typecheck` (`bun check`) and +`bun run format:check` (`bun fmt --check .`). `bun run lint` is retained only as a +compatibility alias: TypeScript checking does not enforce the retired oxlint rules. +Documentation audit path filters include `.bun-version`, `package.json` and +`bun.lock` so runtime/dependency changes are verified too. + ### Composite action: `start-preview-server` Shared by `a11y.yaml` and `responsive.yaml`. Starts `bun run docs:preview --port ` diff --git a/.github/skills/refactoring.md b/.github/skills/refactoring.md index ce50bb6..3963ed0 100644 --- a/.github/skills/refactoring.md +++ b/.github/skills/refactoring.md @@ -115,8 +115,8 @@ bun run knip ```bash bun test # full suite passes — same behaviour before and after -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no formatting diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no formatting diff bun run knip # no unused exports or imports bun run build.ts # binary compiles without errors ``` diff --git a/.github/skills/release.md b/.github/skills/release.md index 7e8c270..7c5bd3b 100644 --- a/.github/skills/release.md +++ b/.github/skills/release.md @@ -160,8 +160,8 @@ The version selector in the VitePress nav (`themeConfig.versionSelector`) reads ```bash bun test # full suite green -bun run lint # oxlint — zero errors -bun run format:check # oxfmt — no diff +bun run typecheck # bun check — zero type errors +bun run format:check # bun fmt --check — no diff bun run knip # no unused exports bun run build.ts # binary compiles for current platform diff --git a/.github/workflows/a11y.yaml b/.github/workflows/a11y.yaml index 3e9fa54..89b4430 100644 --- a/.github/workflows/a11y.yaml +++ b/.github/workflows/a11y.yaml @@ -24,6 +24,9 @@ on: pull_request: paths: - "docs/**" + - ".bun-version" + - "package.json" + - "bun.lock" - ".github/workflows/a11y.yaml" - ".pa11yci.json" workflow_dispatch: @@ -51,7 +54,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 971a8e0..548345b 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -1,3 +1,5 @@ +# Validate and compile release tags for each supported target, then publish +# the self-contained binaries and compatibility aliases in a GitHub Release. name: CD on: @@ -55,11 +57,17 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile + - name: Type check (bun check) + run: bun run typecheck + + - name: Check formatting (bun fmt) + run: bun run format:check + - name: Run tests run: bun test diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af78b13..07e8595 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,3 +1,5 @@ +# Validate pull requests and manually dispatched snapshot branches with tests, +# native Bun formatting/type checking and knip; retain the coverage PR report. name: CI on: @@ -12,11 +14,20 @@ on: # evaluation. workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: test: + # Keep the existing status-check name for branch protection compatibility. name: Test · Format · Lint · Dead code runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: @@ -26,7 +37,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile @@ -42,11 +53,11 @@ jobs: lcov-file: coverage/lcov.info continue-on-error: true - - name: Check formatting (oxfmt) + - name: Check formatting (bun fmt) run: bun run format:check - - name: Lint (oxlint) - run: bun run lint + - name: Type check (bun check) + run: bun run typecheck - name: Dead code detection (knip) run: bun run knip diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b9ffbeb..6876f36 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,6 +9,9 @@ on: branches: [main] paths: - "docs/**" + - ".bun-version" + - "package.json" + - "bun.lock" - ".github/workflows/docs.yaml" tags: # Glob pattern (not regex): matches v1.0.0, v2.0.0, v10.0.0 … @@ -66,7 +69,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile @@ -158,7 +161,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/lighthouse.yaml b/.github/workflows/lighthouse.yaml index d7f54c4..c8d623f 100644 --- a/.github/workflows/lighthouse.yaml +++ b/.github/workflows/lighthouse.yaml @@ -32,6 +32,9 @@ on: pull_request: paths: - "docs/**" + - ".bun-version" + - "package.json" + - "bun.lock" - ".github/workflows/lighthouse.yaml" - ".lhci.config.cjs" - "scripts/generate-lhci-report.ts" @@ -60,7 +63,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/responsive.yaml b/.github/workflows/responsive.yaml index 253265b..32f7922 100644 --- a/.github/workflows/responsive.yaml +++ b/.github/workflows/responsive.yaml @@ -8,6 +8,9 @@ on: pull_request: paths: - "docs/**" + - ".bun-version" + - "package.json" + - "bun.lock" - "playwright.config.ts" - "scripts/responsive.pw.ts" - ".github/workflows/responsive.yaml" @@ -28,6 +31,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index 55c15df..0000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index 2dbe09d..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": null, - "categories": { - "correctness": "error", - "suspicious": "warn" - }, - "rules": {}, - "overrides": [ - { - "files": ["**/*.test.ts"], - "rules": { - "no-control-regex": "off" - } - }, - { - "files": ["**/*.vue"], - "rules": { - "no-underscore-dangle": "off" - } - } - ], - "settings": { - "jsx-a11y": { - "polymorphicPropName": null, - "components": {}, - "attributes": {} - }, - "next": { - "rootDir": [] - }, - "react": { - "formComponents": [], - "linkComponents": [], - "version": null, - "componentWrapperFunctions": [] - }, - "jsdoc": { - "ignorePrivate": false, - "ignoreInternal": false, - "ignoreReplacesDocs": true, - "overrideReplacesDocs": true, - "augmentsExtendsReplacesDocs": false, - "implementsReplacesDocs": false, - "exemptDestructuredRootsFromChecks": false, - "tagNamePreference": {} - }, - "vitest": { - "typecheck": false - } - }, - "env": { - "builtin": true - }, - "globals": {}, - "ignorePatterns": [] -} diff --git a/AGENTS.md b/AGENTS.md index f930711..cf607a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,13 +8,11 @@ This file provides context for AI coding agents (GitHub Copilot, Claude, Gemini, ## Runtime & toolchain -| Tool | Version | -| -------------- | -------------------------------------------------------- | -| **Bun** | ≥ 1.4 (runtime, bundler, test runner, package manager) | -| **TypeScript** | via Bun (no separate `tsc` invocation needed at runtime) | -| **oxlint** | linter (`bun run lint`) | -| **oxfmt** | formatter (`bun run format`) | -| **knip** | dead-code detector (`bun run knip`) | +| Tool | Version | +| -------------- | ---------------------------------------------------------------------- | +| **Bun** | ≥ 1.4.4 (runtime, bundler, tests, packages, formatting, type checking) | +| **TypeScript** | via Bun (no separate `tsc` invocation needed at runtime) | +| **knip** | dead-code detector (`bun run knip`) | There is **no Node.js / npm** involved. Always use `bun` commands. @@ -47,16 +45,31 @@ All TypeScript tests use Bun's built-in test runner (`@jest/globals`-compatible Shell-integration tests use [bats-core](https://github.com/bats-core/bats-core) (`brew install bats-core` or install via your package manager). They cover `install_completions()` from `install.sh` and are located in `install.test.bats`. -## Linting & formatting +## Type checking & formatting ```bash -bun run lint # oxlint — must pass before submitting -bun run format # oxfmt write (auto-fix) -bun run format:check # oxfmt check (CI check) +bun run typecheck # bun check — strict TypeScript checking +bun run lint # compatibility alias for typecheck (not a linter) +bun run format # bun fmt write (auto-fix) +bun run format:check # bun fmt --check (CI check) bun run knip # detect unused exports / files ``` -Always run `bun run lint` and `bun run format:check` before considering a change complete. +Always run `bun run typecheck` and `bun run format:check` before considering a change complete. + +`bun check` reads `tsconfig.json` and checks application code, tests and supporting +TypeScript scripts. It does not check Vue templates or replace general-purpose +lint rules. The former oxlint correctness/suspicious rules are intentionally retired; +do not reintroduce a standalone linter or formatter as part of routine changes. + +Formatting options live in `[fmt]` in `bunfig.toml` (100-column print width). +`knip.json` ignores `fmt` and `check` as binaries because knip currently mistakes +these native Bun subcommands for external executables. +All CI/CD and docs workflows read the pinned Bun version from `.bun-version`. +When updating that version, keep the minimum `engines.bun` requirement and current +installation/contributor documentation consistent. Compiled binaries do not need Bun. +No repository custom agent definitions are currently shipped; any future agents +must use the same native tooling and validation commands. ## Project layout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e67162..5a4a67c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for taking the time to contribute! This document describes how to set ## Prerequisites -- [Bun](https://bun.sh) ≥ 1.4 +- [Bun](https://bun.sh) ≥ 1.4.4 (CI uses the version pinned in `.bun-version`) - A GitHub personal access token with `repo` scope (for integration testing) ## Development setup @@ -106,14 +106,20 @@ Compiled binaries require no runtime dependencies and can be distributed as a si - TypeScript throughout. - Pure functions wherever possible (makes unit testing straightforward). - Side-effectful code (CLI parsing, API calls, TTY interaction) is isolated in `github-code-search`, `src/api.ts`, and `src/tui.ts`. -- Run `bun run lint` (oxlint) — must pass with zero errors before submitting. -- Run `bun run format:check` (oxfmt) — auto-fix locally with `bun run format`. +- Run `bun run typecheck` (`bun check`) — strict TypeScript checking of application code, tests and supporting TypeScript scripts. +- Run `bun run format:check` (`bun fmt --check .`) — auto-fix locally with `bun run format` (`bun fmt .`). + +Formatting options live in `[fmt]` in `bunfig.toml`, retaining the 100-column print +width. No standalone formatter or linter is installed. `bun run lint` remains a +compatibility alias for type checking: `bun check` is not a linter, and the previous +oxlint correctness/suspicious rules are intentionally no longer enforced. Vue +templates are validated by the documentation build, not by `bun check`. ## Submitting a pull request 1. Fork the repository and create a branch: `git checkout -b my-feature`. 2. Make your changes, add or update tests. -3. Ensure `bun test` passes. +3. Ensure `bun test`, `bun run typecheck`, `bun run format:check`, `bun run knip` and `bun run build.ts` pass. 4. Open a pull request against `main` with a clear description of the motivation and changes. If your PR touches docs (`docs/**`), also verify: diff --git a/README.md b/README.md index 0270c72..18b9ee8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,14 @@ keyboard- and mouse-driven TUI, fine-grained extract selection, markdown/JSON ou ## Requirements -- **Bun** ≥ 1.4 (runtime and package manager) +- **Bun** ≥ 1.4.4 when running or building from source; compiled binaries do not require Bun - **GitHub Token** with `repo` and `read:org` scopes +Development uses Bun's built-in `bun fmt` and `bun check`, with no standalone +formatter or linter dependency. Run `bun run format`, `bun run format:check` and +`bun run typecheck`; `bun run lint` is a compatibility alias for type checking, +not equivalent lint coverage. See [Contributing](CONTRIBUTING.md) for setup and checks. + ## Quick start **macOS / Linux** diff --git a/build.ts b/build.ts index c5484fe..fb7c29d 100644 --- a/build.ts +++ b/build.ts @@ -184,7 +184,7 @@ if (import.meta.main) { const icoPath = `${import.meta.dir}/docs/public/icons/favicon.ico`; const currentYear = new Date().getFullYear(); - const authorName = typeof author === "string" ? author : author.name; + const authorName = author; await Bun.$`mkdir -p dist`; await Bun.build({ diff --git a/bun.lock b/bun.lock index b033674..ef2f4e9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,8 +13,6 @@ "bun-types": "^1.4.2", "knip": "^6.39.0", "mermaid": "^12.2.0", - "oxfmt": "^0.74.0", - "oxlint": "^1.86.0", "pa11y-ci": "^5.0.0", "sharp": "^0.35.5", "vitepress": "^1.6.4", @@ -299,82 +297,6 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA=="], - - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-FJW3sATVvrWbV64al8xofCPZj4500Z/1W0ad7Tg1rcCUyh0EMbuSrsf3jsZS7CEbwQ5fQ0mRhmkc129GFgofSQ=="], - - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TfmNKBD30dGyMu9GCLiaw2rt/z7fms9LS+5cKcNOyWst0QgAs8oZs6wPxMbVaaDVE0/PCdCosgWM1vbu6f5NUg=="], - - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Usf8+DfC4Q3o/MD8FkN47GrI6y0KbhQ4KhZ2HGzMZcXjcW1AeldwbUn51aKih+1TVuJFF9qkNm6X1nzm6NYng=="], - - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-eL+1O3gykw1XihbZh2MRAciQvU2988LByrnjhyo/dmNG74X4oZ7XzigRoWPRFLfJCp8hzRkp/LbEhwe7uuT2eA=="], - - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Xt0QtO2hJ4stnul8IL4LJGVLyp0xnc6ccN9fZ30yuubv8FWSRYxWwd1Za62KsEqAfmPSazoS/cLSUUJpja6npA=="], - - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/4wYQ0gZl4vUR0QoBTGaay73FmQ1GAjUa9z9101TR2xbGNrg4a+grazlR0yzEOe3/E+PJr5BGfj66MMzm/54vg=="], - - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-846bKzF+HJd+JkOFh3rxzmYvSq4Cm+GMT+p+mW7S13ZYo6GAAGv8bL8cAOi/XcdeMqxEJDCIMTVQLVBXHMCdig=="], - - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nI5FJwZ4Flo6CBdscpwxymZXxO3m3IErQJX55ZS6h+ObfGXu8FostL85L/RWPFaCLnIVE4/WfLw+2q8XvVEeUA=="], - - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.74.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BoQYKuqUdsKAFAkK6DLBvkdv4+t0hppSm79sB0wIcE2o/eOwNj+6Z3OlKY4q3Eta/tlssP65FaS9vHn8TeF7vQ=="], - - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-FSroRrM12+jCdSQklF9aOxsKpI6wFs4ZCo/1cSYG80RWZS6z4LV+l9tXRMT8lDGu+yohIZBwi9M76FzGPFDE2A=="], - - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-0VLQD0dhGBLY9Ik/w+otMvD81D0YXi4VDwql65macV+O9UzmSWmUr6CKf+dMtysDeC0PnG3y6kBkRO08BJx52g=="], - - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-6X7BbZNz34+OHM6Oqjf6TRcUyRn6aRcB4VtIDZAF/uQRyeVhg+G2hv6MlMvmnRekVSyTJ/8rG/u8W2dRFW6glg=="], - - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4kYIQK53yJXrxwxSzx8H2bojlf6/KezLGYLRXzV9QCCkoiCsvBTCid9Bg83dUPTLPa3g9K0730f6MKDbck1+OA=="], - - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MfWz4wDREjp/JQDmuxtKieix4FHGa1z5rBvB3betdcPwZvf5fqeQrlCm1mmoyuUVQOh9Pt3M61HwN94kJCxw7Q=="], - - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.74.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6rmAApkq5s1kbDjvNI6Nl5A+JqQkLORb2sR941pf5HWsrd01WGKyagK/B4vX9+kh1FfvRkNgEu59FHvVk4H8cQ=="], - - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+5SpRUnZNGeXTyumHnpHa791tQtt04qZs076WT8eTcGF2OJ631VLnf7S0kQSPuDG+PtJM0phadEB2WAr5n1w9g=="], - - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.74.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1Fg+wYBO3Dsti1QX+/6EYHjeQzLC84tLOAqPu/r7ah3UJlUs9C/DB703Ay9Vcg+8VMzqZA2P5VGhzJraVUD4OQ=="], - - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-y9jXUgPBm2TZIWbQhMYRRYAQM57+r4fbykgc+j24ICIIZl+pcuoNhnq1CRGm3oFiHnEUnZO00qOtSPGQdJ2ziQ=="], - - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], - - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], - - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], - - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], - - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], - - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], - - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], - - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], - - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], - - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], - - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], - - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], - - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], - - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], - - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], - - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], - - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], - - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], - "@pa11y/html_codesniffer": ["@pa11y/html_codesniffer@2.6.0", "", {}, "sha512-BKA7qG8NyaIBdCBDep0hYuYoF/bEyWJprE6EEVJOPiwj80sSiIKDT8LUVd19qKhVqNZZD3QvJIdFZ35p+vAFPg=="], "@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.53", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA=="], @@ -1171,10 +1093,6 @@ "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], - "oxfmt": ["oxfmt@0.74.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.74.0", "@oxfmt/binding-android-arm64": "0.74.0", "@oxfmt/binding-darwin-arm64": "0.74.0", "@oxfmt/binding-darwin-x64": "0.74.0", "@oxfmt/binding-freebsd-x64": "0.74.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.74.0", "@oxfmt/binding-linux-arm-musleabihf": "0.74.0", "@oxfmt/binding-linux-arm64-gnu": "0.74.0", "@oxfmt/binding-linux-arm64-musl": "0.74.0", "@oxfmt/binding-linux-ppc64-gnu": "0.74.0", "@oxfmt/binding-linux-riscv64-gnu": "0.74.0", "@oxfmt/binding-linux-riscv64-musl": "0.74.0", "@oxfmt/binding-linux-s390x-gnu": "0.74.0", "@oxfmt/binding-linux-x64-gnu": "0.74.0", "@oxfmt/binding-linux-x64-musl": "0.74.0", "@oxfmt/binding-openharmony-arm64": "0.74.0", "@oxfmt/binding-win32-arm64-msvc": "0.74.0", "@oxfmt/binding-win32-ia32-msvc": "0.74.0", "@oxfmt/binding-win32-x64-msvc": "0.74.0" }, "peerDependencies": { "@astrojs/compiler-rs": "^0.4.0 || ^0.5.0", "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["@astrojs/compiler-rs", "svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A=="], - - "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], @@ -1379,8 +1297,6 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], - "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "tldts-icann": ["tldts-icann@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" } }, "sha512-NFxmRT2lAEMcCOBgeZ0NuM0zsK/xgmNajnY6n4S1mwAKocft2s2ise1O3nQxrH3c+uY6hgHUV9GGNVp7tUE4Sg=="], diff --git a/bunfig.toml b/bunfig.toml index 0635736..7a30037 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,6 @@ +[fmt] +printWidth = 100 + [test] coverage = true coverageReporter = ["text", "lcov"] diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 1e15799..5c6717b 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -3,7 +3,7 @@ The only runtime prerequisite is a **GitHub personal access token** (or the [GitHub CLI](https://cli.github.com/), see below). The pre-compiled binary is self-contained and has no runtime dependency — you do not need Bun to run it. ::: tip Building from source? -If you want to build `github-code-search` from source, you will additionally need [Bun](https://bun.sh) ≥ 1.4. See the [Installation guide](/getting-started/installation#from-source). +If you want to build `github-code-search` from source, you will additionally need [Bun](https://bun.sh) ≥ 1.4.4. See the [Installation guide](/getting-started/installation#from-source). ::: ## GitHub token diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 96893f4..52b9d48 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -47,7 +47,8 @@ The script automatically adds the install directory to your user PATH. New termi ## From source -Requires [Bun](https://bun.sh) ≥ 1.4. +Requires [Bun](https://bun.sh) ≥ 1.4.4. CI/CD and documentation workflows use the +version pinned in `.bun-version`. Pre-compiled binaries do not require Bun. ```bash git clone https://github.com/fulll/github-code-search @@ -57,6 +58,19 @@ bun run build.ts # → produces dist/github-code-search ``` +For development, use Bun's native formatter and TypeScript checker: + +```bash +bun run format # bun fmt . (writes formatted files) +bun run format:check # bun fmt --check . (read-only verification) +bun run typecheck # bun check (strict TypeScript checking) +``` + +`bun run lint` is retained as a compatibility alias for `typecheck`. Type checking +does not replace general-purpose lint rules; the former standalone oxlint rules +are intentionally no longer enforced. Formatting options are configured in +`bunfig.toml`, and no standalone formatter or linter dependency is needed. + Copy the binary wherever you like: ```bash diff --git a/knip.json b/knip.json index d5f1bd8..b34bf9d 100644 --- a/knip.json +++ b/knip.json @@ -2,5 +2,5 @@ "$schema": "https://unpkg.com/knip@latest/schema.json", "ignore": ["docs/**", ".lhci.config.cjs"], "ignoreDependencies": ["bun-types", "mermaid", "vitepress-mermaid-renderer"], - "ignoreBinaries": ["bats"] + "ignoreBinaries": ["bats", "fmt", "check"] } diff --git a/package.json b/package.json index 8743276..0245b56 100644 --- a/package.json +++ b/package.json @@ -33,13 +33,17 @@ "bin": { "github-code-search": "./github-code-search.ts" }, + "engines": { + "bun": ">=1.4.4" + }, "scripts": { "build": "bun build.ts", "test": "bun test", "test:bats": "bats install.test.bats", - "format": "oxfmt --write .", - "format:check": "oxfmt --check .", - "lint": "oxlint .", + "format": "bun fmt .", + "format:check": "bun fmt --check .", + "typecheck": "bun check", + "lint": "bun run typecheck", "knip": "knip", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", @@ -61,8 +65,6 @@ "bun-types": "^1.4.2", "knip": "^6.39.0", "mermaid": "^12.2.0", - "oxfmt": "^0.74.0", - "oxlint": "^1.86.0", "pa11y-ci": "^5.0.0", "sharp": "^0.35.5", "vitepress": "^1.6.4", diff --git a/scripts/responsive.pw.ts b/scripts/responsive.pw.ts index a6909b3..b067e48 100644 --- a/scripts/responsive.pw.ts +++ b/scripts/responsive.pw.ts @@ -88,7 +88,6 @@ async function findOverflowingElements(page: Page): Promise { const vw = window.innerWidth; const hits: { selector: string; overflow: number }[] = []; - // oxlint-disable-next-line unicorn/consistent-function-scoping -- must stay inside page.evaluate() function isContainedByAncestor(el: Element): boolean { let parent = el.parentElement; while (parent && parent !== document.documentElement) { diff --git a/src/api-utils.test.ts b/src/api-utils.test.ts index a94d833..0ac6017 100644 --- a/src/api-utils.test.ts +++ b/src/api-utils.test.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mockFetch } from "./test-setup.ts"; import { concurrentMap, fetchWithRetry, formatRetryWait, paginatedFetch } from "./api-utils.ts"; const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout; +it("preserves Bun's fetch.preconnect when mocking fetch", async () => { + mockFetch(async () => new Response("mocked")); + expect(globalThis.fetch.preconnect).toBe(originalFetch.preconnect); + expect(await (await fetch("https://example.com")).text()).toBe("mocked"); +}); + // Make all delays instant in tests so the suite stays fast beforeEach(() => { // biome-ignore lint/suspicious/noExplicitAny: test-only shim @@ -23,10 +30,10 @@ afterEach(() => { describe("fetchWithRetry", () => { it("returns a 200 response immediately without retrying", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}); expect(res.status).toBe(200); @@ -35,10 +42,10 @@ describe("fetchWithRetry", () => { it("returns a non-retryable error response (404) immediately without retrying", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; return new Response("not found", { status: 404 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(404); @@ -47,10 +54,10 @@ describe("fetchWithRetry", () => { it("returns a non-retryable error response (401) immediately without retrying", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; return new Response("unauthorized", { status: 401 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(401); @@ -59,11 +66,11 @@ describe("fetchWithRetry", () => { it("retries on 429 and succeeds on the second attempt", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) return new Response("rate limited", { status: 429 }); return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(200); @@ -72,7 +79,7 @@ describe("fetchWithRetry", () => { it("retries on 429 and reads the Retry-After header", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) { return new Response("rate limited", { @@ -81,7 +88,7 @@ describe("fetchWithRetry", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(200); @@ -90,11 +97,11 @@ describe("fetchWithRetry", () => { it("retries on 503 and succeeds on the third attempt", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls < 3) return new Response("service unavailable", { status: 503 }); return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(200); @@ -103,10 +110,10 @@ describe("fetchWithRetry", () => { it("returns the last 429 response after maxRetries exhausted", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; return new Response("still rate limited", { status: 429 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 2); expect(res.status).toBe(429); @@ -116,10 +123,10 @@ describe("fetchWithRetry", () => { it("passes the request options to fetch", async () => { let capturedOptions: RequestInit | undefined; - globalThis.fetch = (async (_url: string | URL | Request, opts?: RequestInit) => { + mockFetch(async (_url: string | URL | Request, opts?: RequestInit) => { capturedOptions = opts; return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); await fetchWithRetry("https://example.com", { headers: { Authorization: "Bearer token" }, @@ -169,7 +176,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { let calls = 0; // reset = now + 1 second → well within the 10 s threshold const resetTimestamp = Math.ceil((Date.now() + 1_000) / 1_000); - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) { return new Response("rate limited", { @@ -181,7 +188,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(200); @@ -191,7 +198,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { it("throws a human-readable error when the rate-limit reset is far in the future", async () => { // reset = now + 5 minutes → exceeds the 10 s auto-retry threshold const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000); - globalThis.fetch = (async () => { + mockFetch(async () => { return new Response("rate limited", { status: 403, headers: { @@ -199,7 +206,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { "x-ratelimit-reset": String(resetTimestamp), }, }); - }) as typeof fetch; + }); await expect(fetchWithRetry("https://example.com", {}, 3)).rejects.toThrow( /GitHub API rate limit exceeded\. Please retry in \d+ minute/, @@ -208,10 +215,10 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { it("does NOT treat a plain 403 (non-rate-limit) as retryable", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; return new Response("forbidden", { status: 403 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(403); @@ -220,7 +227,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { it("retries on 403 secondary rate-limit (Retry-After header) within threshold", async () => { let calls = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) { return new Response("secondary rate limited", { @@ -229,7 +236,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3); expect(res.status).toBe(200); @@ -239,7 +246,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { it("calls onRateLimit for 403 secondary rate-limit (Retry-After) exceeding threshold", async () => { let calls = 0; const callbackArgs: number[] = []; - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) { return new Response("secondary rate limited", { @@ -248,7 +255,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const res = await fetchWithRetry("https://example.com", {}, 3, async (ms) => { callbackArgs.push(ms); @@ -260,12 +267,12 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); it("throws with a human-readable error when Retry-After header exceeds threshold", async () => { - globalThis.fetch = (async () => { + mockFetch(async () => { return new Response("rate limited", { status: 429, headers: { "Retry-After": "300" }, // 5 minutes }); - }) as typeof fetch; + }); await expect(fetchWithRetry("https://example.com", {}, 3)).rejects.toThrow( /GitHub API rate limit exceeded\. Please retry in \d+ minute/, @@ -277,7 +284,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { const callbackArgs: number[] = []; // reset = now + 5 minutes → exceeds the 10 s threshold const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000); - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls === 1) { return new Response("rate limited", { @@ -289,7 +296,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const onRateLimit = async (waitMs: number) => { callbackArgs.push(waitMs); @@ -306,7 +313,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000); // First 2 calls: rate-limited (long wait); 3rd call: success. // maxRetries = 5 so neither longWaitAttempts (2) nor attempt (0) hits the cap. - globalThis.fetch = (async () => { + mockFetch(async () => { calls++; if (calls < 3) { return new Response("rate limited", { @@ -318,7 +325,7 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { }); } return new Response("ok", { status: 200 }); - }) as typeof fetch; + }); const callbackCount = { n: 0 }; const res = await fetchWithRetry( @@ -337,14 +344,16 @@ describe("fetchWithRetry – 403 rate-limit handling", () => { const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000); let callbackCalls = 0; // Always rate-limited — the loop must not run forever - globalThis.fetch = (async () => - new Response("rate limited", { - status: 403, - headers: { - "x-ratelimit-remaining": "0", - "x-ratelimit-reset": String(resetTimestamp), - }, - })) as typeof fetch; + mockFetch( + async () => + new Response("rate limited", { + status: 403, + headers: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetTimestamp), + }, + }), + ); await expect( fetchWithRetry("https://example.com", {}, 2, async () => { diff --git a/src/api.test.ts b/src/api.test.ts index 62c70cf..7497d93 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mockFetch } from "./test-setup.ts"; import { buildFetchProgress, buildLineResolutionProgress, @@ -57,14 +58,16 @@ describe("searchCode", () => { }); it("returns items and total from GitHub API", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - items: [{ path: "src/foo.ts" }], - total_count: 1, - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + items: [{ path: "src/foo.ts" }], + total_count: 1, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); const result = await searchCode("myquery", "myorg", "mytoken", 1); expect(result.total).toBe(1); @@ -73,30 +76,32 @@ describe("searchCode", () => { it("passes page parameter in the request URL", async () => { let capturedUrl = ""; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { capturedUrl = url.toString(); return new Response(JSON.stringify({ items: [], total_count: 0 }), { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); await searchCode("q", "org", "tok", 3); expect(capturedUrl).toContain("page=3"); }); it("throws on non-OK API response", async () => { - globalThis.fetch = (async () => new Response("Unauthorized", { status: 401 })) as typeof fetch; + mockFetch(async () => new Response("Unauthorized", { status: 401 })); await expect(searchCode("q", "org", "token")).rejects.toThrow("401"); }); it("returns empty items and zero total on empty results", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [], total_count: 0 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [], total_count: 0 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const result = await searchCode("q", "org", "tok"); expect(result.items).toHaveLength(0); @@ -128,11 +133,13 @@ describe("fetchAllResults", () => { }); it("returns empty array when API returns no items", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [], total_count: 0 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [], total_count: 0 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const results = await fetchAllResults("q", "org", "tok"); expect(results).toHaveLength(0); @@ -150,11 +157,13 @@ describe("fetchAllResults", () => { }, ], }; - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const results = await fetchAllResults("hello", "org", "tok"); expect(results).toHaveLength(1); @@ -179,11 +188,13 @@ describe("fetchAllResults", () => { }, ], }; - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const results = await fetchAllResults("world", "org", "tok"); expect(results[0].textMatches[0].matches[0].text).toBe("world"); @@ -196,11 +207,13 @@ describe("fetchAllResults", () => { repository: { full_name: "org/oldie", archived: true }, text_matches: [], }; - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const results = await fetchAllResults("old", "org", "tok"); expect(results[0].archived).toBe(true); @@ -212,11 +225,13 @@ describe("fetchAllResults", () => { html_url: "https://github.com/org/repo/blob/main/src/bar.ts", repository: { full_name: "org/repo", archived: false }, }; - globalThis.fetch = (async () => - new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify({ items: [fakeItem], total_count: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const results = await fetchAllResults("bar", "org", "tok"); expect(results[0].textMatches).toHaveLength(0); @@ -224,7 +239,7 @@ describe("fetchAllResults", () => { it("stops fetching once total is reached in first page", async () => { let callCount = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { callCount++; return new Response( JSON.stringify({ @@ -240,7 +255,7 @@ describe("fetchAllResults", () => { }), { status: 200, headers: { "content-type": "application/json" } }, ); - }) as typeof fetch; + }); await fetchAllResults("q", "org", "tok"); expect(callCount).toBe(1); @@ -251,7 +266,7 @@ describe("fetchAllResults", () => { // Page 1: exactly 100 items → full page → continue // Page 2: 3 items → partial page → stop let searchPage = 0; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response("", { status: 404 }); @@ -265,7 +280,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("q", "org", "tok"); expect(results).toHaveLength(103); @@ -286,7 +301,7 @@ describe("fetchAllResults", () => { }, ], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response(fileContent, { status: 200 }); @@ -295,7 +310,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("doSomething", "org", "tok"); // "const x = doSomething()" starts on line 3; match offset 10 is within that line @@ -314,7 +329,7 @@ describe("fetchAllResults", () => { }, ], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { throw new Error("network error"); @@ -323,7 +338,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("hello", "org", "tok"); // Fragment starts at line 1 (fallback); offset 0 → line 1 @@ -334,7 +349,7 @@ describe("fetchAllResults", () => { // Regression guard for the 422 "Cannot access beyond the first 1000 results" // error that occurred when total_count was an exact multiple of 100. let searchCallCount = 0; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response("", { status: 404 }); @@ -347,7 +362,7 @@ describe("fetchAllResults", () => { }), { status: 200, headers: { "content-type": "application/json" } }, ); - }) as typeof fetch; + }); const results = await fetchAllResults("q", "org", "tok"); expect(results).toHaveLength(1000); @@ -367,7 +382,7 @@ describe("fetchAllResults", () => { }, ], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response("Not Found", { status: 404 }); @@ -376,7 +391,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("hello", "org", "tok"); expect(results[0].textMatches[0].matches[0].line).toBe(1); @@ -390,7 +405,7 @@ describe("fetchAllResults", () => { repository: { full_name: "org/repo", archived: false }, text_matches: [{ fragment: "const x = doSomething()", matches: [] }], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response(fileContent, { status: 200 }); @@ -399,7 +414,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("doSomething", "org", "tok", undefined, true); expect(results[0].fileContent).toBe(fileContent); @@ -412,7 +427,7 @@ describe("fetchAllResults", () => { repository: { full_name: "org/repo", archived: false }, text_matches: [{ fragment: "hello world", matches: [] }], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response("Not Found", { status: 404 }); @@ -421,7 +436,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const results = await fetchAllResults("hello", "org", "tok", undefined, true); expect(results[0].fileContent).toBeUndefined(); @@ -435,7 +450,7 @@ describe("fetchAllResults", () => { repository: { full_name: "org/repo", archived: false }, text_matches: [{ fragment: "const x = doSomething()", matches: [] }], }; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (new URL(urlStr).hostname === "raw.githubusercontent.com") { return new Response(fileContent, { status: 200 }); @@ -444,7 +459,7 @@ describe("fetchAllResults", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); // No keepFileContent argument — defaults to false. const results = await fetchAllResults("doSomething", "org", "tok"); @@ -460,18 +475,20 @@ describe("fetchRepoTeams", () => { }); it("returns empty map when no teams match the prefix", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify([{ slug: "backend-core", name: "Backend" }]), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + mockFetch( + async () => + new Response(JSON.stringify([{ slug: "backend-core", name: "Backend" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false); expect(result.size).toBe(0); }); it("maps repos to matching team slugs", async () => { - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/orgs/myorg/teams?")) { return new Response(JSON.stringify([{ slug: "frontend-web", name: "Frontend Web" }]), { @@ -484,14 +501,14 @@ describe("fetchRepoTeams", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false); expect(result.get("myorg/my-repo")).toEqual(["frontend-web"]); }); it("assigns multiple team slugs to a repo that belongs to several matching teams", async () => { - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/orgs/myorg/teams?")) { return new Response( @@ -507,7 +524,7 @@ describe("fetchRepoTeams", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false); const slugs = result.get("myorg/shared-ui") ?? []; @@ -516,13 +533,13 @@ describe("fetchRepoTeams", () => { }); it("throws when the teams list request fails", async () => { - globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as typeof fetch; + mockFetch(async () => new Response("Forbidden", { status: 403 })); await expect(fetchRepoTeams("myorg", "tok", ["frontend"], false)).rejects.toThrow("403"); }); it("silently skips a team's repos when its repo list request fails", async () => { - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/orgs/myorg/teams?")) { return new Response(JSON.stringify([{ slug: "frontend-web", name: "FE" }]), { @@ -531,7 +548,7 @@ describe("fetchRepoTeams", () => { }); } return new Response("Not Found", { status: 404 }); - }) as typeof fetch; + }); const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false); expect(result.size).toBe(0); @@ -539,7 +556,7 @@ describe("fetchRepoTeams", () => { it("paginates through teams when the first page returns 100 items", async () => { let teamPage = 0; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/teams?")) { teamPage++; @@ -564,7 +581,7 @@ describe("fetchRepoTeams", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); await fetchRepoTeams("myorg", "tok", ["frontend"], false); expect(teamPage).toBe(2); @@ -572,7 +589,7 @@ describe("fetchRepoTeams", () => { it("paginates through repos when a team's first repo page returns 100 items", async () => { let repoPage = 0; - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/teams?")) { return new Response(JSON.stringify([{ slug: "frontend-web", name: "FE" }]), { @@ -595,7 +612,7 @@ describe("fetchRepoTeams", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const result = await fetchRepoTeams("myorg", "tok", ["frontend"], false); expect(result.has("myorg/repo-extra")).toBe(true); @@ -604,7 +621,7 @@ describe("fetchRepoTeams", () => { it("throws a clean rate-limit error when the team repo list returns 403 rate-limit", async () => { const resetTimestamp = Math.ceil((Date.now() + 300_000) / 1_000); // 5 minutes from now - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/teams?")) { return new Response(JSON.stringify([{ slug: "frontend-web", name: "FE" }]), { @@ -624,7 +641,7 @@ describe("fetchRepoTeams", () => { }, }, ); - }) as typeof fetch; + }); await expect(fetchRepoTeams("myorg", "tok", ["frontend"], false)).rejects.toThrow( /GitHub API rate limit exceeded\. Please retry in \d+ minute/, @@ -632,7 +649,7 @@ describe("fetchRepoTeams", () => { }); it("prefix matching is case-insensitive", async () => { - globalThis.fetch = (async (url: string | URL | Request) => { + mockFetch(async (url: string | URL | Request) => { const urlStr = url.toString(); if (urlStr.includes("/teams?")) { return new Response(JSON.stringify([{ slug: "Frontend-Web", name: "FE" }]), { @@ -644,7 +661,7 @@ describe("fetchRepoTeams", () => { status: 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }); const result = await fetchRepoTeams("myorg", "tok", ["FRONTEND"], false); expect(result.has("myorg/repo-a")).toBe(true); diff --git a/src/cache.test.ts b/src/cache.test.ts index aeace9c..67f267d 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -195,7 +195,7 @@ describe("writeCache / readCache round-trip", () => { const data = { repo: "org/repo", teams: ["squad-alpha"] }; const key = getCacheKey("myorg", ["squad-"]); writeCache(key, data); - expect(readCache(key)).toEqual(data); + expect(readCache(key)).toEqual(data); }); it("returns null when the key does not exist", () => { @@ -222,7 +222,7 @@ describe("writeCache / readCache round-trip", () => { const key = getCacheKey("myorg", ["fresh-"]); const payload = [{ full_name: "org/repo" }]; writeCache(key, payload); - expect(readCache(key)).toEqual(payload); + expect(readCache(key)).toEqual(payload); }); it("creates the cache directory if it does not exist", () => { @@ -232,7 +232,7 @@ describe("writeCache / readCache round-trip", () => { const key = getCacheKey("myorg", ["squad-"]); // Should not throw expect(() => writeCache(key, { ok: true })).not.toThrow(); - expect(readCache(key)).toEqual({ ok: true }); + expect(readCache<{ ok: boolean }>(key)).toEqual({ ok: true }); }); it("silently ignores write errors on read-only paths", () => { @@ -296,6 +296,6 @@ describe("Path traversal vulnerability mitigation", () => { const safeKey = "teams__myorg__squad-.json"; const data = { teams: ["squad-alpha"] }; writeCache(safeKey, data); - expect(readCache(safeKey)).toEqual(data); + expect(readCache(safeKey)).toEqual(data); }); }); diff --git a/src/output.test.ts b/src/output.test.ts index 894dd0f..c3a2ca7 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -814,9 +814,9 @@ describe("buildOutput", () => { expect(parsed.results).toBeDefined(); }); - it("dispatches to markdown when format is text", () => { + it("dispatches to markdown when format is markdown", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; - const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "text"); + const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "markdown"); expect(out).toContain("- **myorg/repoA**"); }); @@ -827,9 +827,9 @@ describe("buildOutput", () => { expect(parsed.results[0].matches).toBeUndefined(); }); - it("passes repo-only outputType to text dispatcher", () => { + it("passes repo-only outputType to markdown dispatcher", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; - const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "text", "repo-only"); + const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "markdown", "repo-only"); expect(out).not.toContain("[src/foo.ts]"); expect(out).toContain("myorg/repoA"); }); diff --git a/src/render.test.ts b/src/render.test.ts index fb5db95..6c726ce 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -60,7 +60,7 @@ describe("highlightFragment", () => { it("highlights matched segment", () => { const result = highlightFragment( "hello world", - [{ text: "world", indices: [6, 11] }], + [{ text: "world", indices: [6, 11], line: 1, col: 7 }], "file.ts", ); const joined = result.join("\n"); diff --git a/src/render/highlight.test.ts b/src/render/highlight.test.ts index afae4e1..7562062 100644 --- a/src/render/highlight.test.ts +++ b/src/render/highlight.test.ts @@ -386,7 +386,11 @@ describe("CSS tokenizer", () => { describe("highlightFragment segment highlighting", () => { it("highlights a matched segment in bold yellow", () => { - const result = highlightFragment("hello world", [{ text: "world", indices: [6, 11] }], "f.ts"); + const result = highlightFragment( + "hello world", + [{ text: "world", indices: [6, 11], line: 1, col: 7 }], + "f.ts", + ); const raw = result.join(""); // Must contain ANSI (bold + yellow applied to segment) expect(raw).toMatch(/\x1b\[/); @@ -395,18 +399,30 @@ describe("highlightFragment segment highlighting", () => { }); it("handles a segment at the start of the line", () => { - const result = highlightFragment("const x = 1", [{ text: "const", indices: [0, 5] }], "f.ts"); + const result = highlightFragment( + "const x = 1", + [{ text: "const", indices: [0, 5], line: 1, col: 1 }], + "f.ts", + ); expect(strip(result.join(""))).toContain("const"); }); it("handles a segment at the end of the line", () => { - const result = highlightFragment("hello world", [{ text: "world", indices: [6, 11] }], "f.ts"); + const result = highlightFragment( + "hello world", + [{ text: "world", indices: [6, 11], line: 1, col: 7 }], + "f.ts", + ); expect(strip(result.join(""))).toContain("world"); }); it("handles overlapping segment that extends past line end (clamped)", () => { // segment goes past end of line → should not throw - const result = highlightFragment("hello", [{ text: "hello!", indices: [0, 999] }], "f.ts"); + const result = highlightFragment( + "hello", + [{ text: "hello!", indices: [0, 999], line: 1, col: 1 }], + "f.ts", + ); expect(strip(result.join(""))).toBe("hello"); }); @@ -414,8 +430,8 @@ describe("highlightFragment segment highlighting", () => { const result = highlightFragment( "foo bar baz", [ - { text: "foo", indices: [0, 3] }, - { text: "baz", indices: [8, 11] }, + { text: "foo", indices: [0, 3], line: 1, col: 1 }, + { text: "baz", indices: [8, 11], line: 1, col: 9 }, ], "f.ts", ); @@ -429,7 +445,7 @@ describe("highlightFragment segment highlighting", () => { const fragment = "foo\nbar\nbaz"; const result = highlightFragment( fragment, - [{ text: "bar", indices: [4, 7] }], // 'bar' starts at offset 4 (after "foo\n") + [{ text: "bar", indices: [4, 7], line: 2, col: 1 }], // 'bar' starts at offset 4 (after "foo\n") "f.ts", ); expect(strip(result[0])).toBe("foo"); diff --git a/src/render/mouse-hit.test.ts b/src/render/mouse-hit.test.ts index 7a03ac2..62d53cd 100644 --- a/src/render/mouse-hit.test.ts +++ b/src/render/mouse-hit.test.ts @@ -16,9 +16,11 @@ function createTestGroup(name: string, repoSelected = true): RepoGroup { folded: false, matches: [ { - filePath: "src/test.ts", + path: "src/test.ts", + repoFullName: name, + htmlUrl: `https://github.com/${name}/blob/main/src/test.ts`, + archived: false, textMatches: [], - extractSelected: false, }, ], extractSelected: [false], @@ -202,7 +204,7 @@ describe("hitTestClick", () => { describe("section rows", () => { it("detects navigate action on section row (no fold/select zones)", () => { const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "section", sectionLabel: "Test Section" }]; + const rows: Row[] = [{ type: "section", repoIndex: -1, sectionLabel: "Test Section" }]; // Section rows don't have fold or select actions, always navigate const result = hitTestClick(groups, rows, 0, FOLD_COLUMN_START, 1); expect(result?.action).toBe("navigate"); @@ -210,7 +212,7 @@ describe("hitTestClick", () => { it("detects navigate action on section row at any column", () => { const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "section", sectionLabel: "Test Section" }]; + const rows: Row[] = [{ type: "section", repoIndex: -1, sectionLabel: "Test Section" }]; const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_START, 1); expect(result?.action).toBe("navigate"); }); diff --git a/src/test-setup.ts b/src/test-setup.ts index 4764c83..6da3169 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -7,3 +7,10 @@ * whenever stdout is a pipe. */ process.env.FORCE_COLOR = "1"; + +/** Replace the callable part of Bun's fetch while preserving its static API. */ +export function mockFetch( + implementation: (...args: Parameters) => ReturnType, +): void { + globalThis.fetch = Object.assign(implementation, { preconnect: globalThis.fetch.preconnect }); +} diff --git a/src/tui.ts b/src/tui.ts index cbfd389..585e275 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -339,7 +339,7 @@ export async function runInteractive( process.on("SIGWINCH", onResize); for await (const chunk of process.stdin) { - const key = chunk.toString(); + const key: string = chunk.toString(); // Try parsing as a mouse event; if it's not a mouse event, treat as keyboard input const mouseEvent = parseMouseEvent(key); @@ -379,7 +379,7 @@ export async function runInteractive( // Includes: title + summary + hints + blank (base 4) + filter bar if active. const headerLines = getHeaderLines( filterMode, - filterPath || filterTarget !== "path" || filterRegex, + filterPath.length > 0 || filterTarget !== "path" || filterRegex, ); // Hit-test the click against visible rows (non-wheel buttons) diff --git a/src/types.ts b/src/types.ts index c8fd941..b588388 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,18 +57,24 @@ export interface RepoGroup { pickedFrom?: string; } -export interface Row { - type: "repo" | "extract" | "section"; - /** -1 for section rows */ - repoIndex: number; - extractIndex?: number; - /** Populated only for `type === "section"` rows. */ - sectionLabel?: string; - /** Nesting depth for `type === "section"` rows produced from a - * `groupByTeamHierarchy` tree (0, 1, 2, …). Rows from the flat - * `groupByTeamPrefix` path are always level 0. Defaults to 0 when unset. */ - sectionLevel?: number; -} +export type Row = + | { + type: "repo" | "extract"; + repoIndex: number; + extractIndex?: number; + sectionLabel?: never; + sectionLevel?: never; + } + | { + type: "section"; + /** -1 for section rows */ + repoIndex: number; + extractIndex?: never; + sectionLabel: string; + /** Nesting depth from `groupByTeamHierarchy` (0, 1, 2, …). + * Flat `groupByTeamPrefix` rows default to 0 when unset. */ + sectionLevel?: number; + }; /** * One labelled group of repos produced by `groupByTeamPrefix` or diff --git a/src/upgrade.test.ts b/src/upgrade.test.ts index 271690f..61957b3 100644 --- a/src/upgrade.test.ts +++ b/src/upgrade.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mockFetch } from "./test-setup.ts"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -172,18 +173,20 @@ describe("fetchLatestRelease", () => { }); it("returns release data from the GitHub API", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v1.2.0", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", - assets: [], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v1.2.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); const release = await fetchLatestRelease("faketoken"); expect(release.tag_name).toBe("v1.2.0"); expect(release.html_url).toBe( @@ -193,24 +196,26 @@ describe("fetchLatestRelease", () => { }); it("works without a token", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v2.0.0", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", - assets: [], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v2.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); const release = await fetchLatestRelease(); expect(release.tag_name).toBe("v2.0.0"); }); it("throws on non-OK response", async () => { - globalThis.fetch = (async () => new Response("Not Found", { status: 404 })) as typeof fetch; + mockFetch(async () => new Response("Not Found", { status: 404 })); await expect(fetchLatestRelease()).rejects.toThrow("404"); }); }); @@ -237,18 +242,20 @@ describe("performUpgrade", () => { }); it("prints 'up to date' when no newer version", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v1.0.0", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", - assets: [], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v1.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); const writes: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); @@ -267,18 +274,20 @@ describe("performUpgrade", () => { }); it("throws when no matching binary asset found in the release", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v9.9.9", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", - assets: [], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v9.9.9", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); await expect(performUpgrade("1.0.0", "/tmp/test-binary-noasset")).rejects.toThrow( "No binary found", @@ -294,29 +303,33 @@ describe("checkForUpdate", () => { }); it("returns the latest version tag when a newer version exists", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v2.0.0", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", - assets: [], - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v2.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", + assets: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); const result = await checkForUpdate("1.0.0"); expect(result).toBe("v2.0.0"); }); it("returns null when already on the latest version", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - tag_name: "v1.0.0", - html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", - assets: [], - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + mockFetch( + async () => + new Response( + JSON.stringify({ + tag_name: "v1.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", + assets: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); const result = await checkForUpdate("1.0.0"); expect(result).toBeNull(); }); @@ -327,13 +340,13 @@ describe("checkForUpdate", () => { }); it("returns null silently on network error", async () => { - globalThis.fetch = (() => Promise.reject(new Error("network failure"))) as typeof fetch; + mockFetch(() => Promise.reject(new Error("network failure"))); const result = await checkForUpdate("1.0.0"); expect(result).toBeNull(); }); it("returns null on non-OK API response", async () => { - globalThis.fetch = (async () => new Response("Not Found", { status: 404 })) as typeof fetch; + mockFetch(async () => new Response("Not Found", { status: 404 })); const result = await checkForUpdate("1.0.0"); expect(result).toBeNull(); }); @@ -342,10 +355,10 @@ describe("checkForUpdate", () => { const controller = new AbortController(); controller.abort(); // fetch will throw an AbortError; checkForUpdate must catch it and return null. - globalThis.fetch = (async (_url: string, opts?: RequestInit) => { + mockFetch(async (_url: string | URL | Request, opts?: RequestInit) => { if (opts?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); throw new Error("fetch should not succeed with an aborted signal"); - }) as typeof fetch; + }); const result = await checkForUpdate("1.0.0", undefined, controller.signal); expect(result).toBeNull(); }); @@ -377,7 +390,7 @@ describe("performUpgrade — download path", () => { const suffix = p === "windows" ? ".exe" : ""; const assetName = `github-code-search-${p}-${process.arch}${suffix}`; let callCount = 0; - globalThis.fetch = (async () => { + mockFetch(async () => { callCount++; if (callCount === 1) { return new Response( @@ -393,7 +406,7 @@ describe("performUpgrade — download path", () => { ); } return downloadResponse; - }) as typeof fetch; + }); } it("throws when the binary download returns a non-OK status", async () => { @@ -445,7 +458,7 @@ describe("performUpgrade — download path", () => { const assetName = `github-code-search-${p}-${process.arch}${suffix}`; let firstCall = true; const origFetch = globalThis.fetch; - globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + mockFetch(async (url: RequestInfo | URL, init?: RequestInit) => { if (firstCall) { firstCall = false; return new Response( @@ -458,7 +471,7 @@ describe("performUpgrade — download path", () => { ); } return origFetch(url, init); - }) as typeof fetch; + }); return assetName; } @@ -467,7 +480,7 @@ describe("performUpgrade — download path", () => { // Override fetch so the download returns a redirect to an attacker-controlled host const origFetch = globalThis.fetch; let releaseApiDone = false; - globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + mockFetch(async (url: RequestInfo | URL, init?: RequestInit) => { if (!releaseApiDone) { releaseApiDone = true; return origFetch(url, init); @@ -476,7 +489,7 @@ describe("performUpgrade — download path", () => { status: 302, headers: { location: "https://attacker.example.com/evil-binary" }, }); - }) as typeof fetch; + }); await expect(performUpgrade("1.0.0", "/tmp/gcs-test-ssrf-redirect")).rejects.toThrow( "Redirect blocked", @@ -487,7 +500,7 @@ describe("performUpgrade — download path", () => { mockReleaseApi(); const origFetch = globalThis.fetch; let releaseApiDone = false; - globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + mockFetch(async (url: RequestInfo | URL, init?: RequestInit) => { if (!releaseApiDone) { releaseApiDone = true; return origFetch(url, init); @@ -496,7 +509,7 @@ describe("performUpgrade — download path", () => { status: 302, headers: { location: "http://github.com/downgrade-attack" }, }); - }) as typeof fetch; + }); await expect(performUpgrade("1.0.0", "/tmp/gcs-test-http-redirect")).rejects.toThrow( "Redirect blocked", @@ -507,7 +520,7 @@ describe("performUpgrade — download path", () => { mockReleaseApi(); const origFetch = globalThis.fetch; let releaseApiDone = false; - globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + mockFetch(async (url: RequestInfo | URL, init?: RequestInit) => { if (!releaseApiDone) { releaseApiDone = true; return origFetch(url, init); @@ -519,7 +532,7 @@ describe("performUpgrade — download path", () => { location: "https://github.com/fulll/github-code-search/releases/download/v9.9.9/binary", }, }); - }) as typeof fetch; + }); await expect(performUpgrade("1.0.0", "/tmp/gcs-test-too-many-redirects")).rejects.toThrow( "Too many redirects", @@ -531,7 +544,7 @@ describe("performUpgrade — download path", () => { const origFetch = globalThis.fetch; let releaseApiDone = false; let redirectDone = false; - globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + mockFetch(async (url: RequestInfo | URL, init?: RequestInit) => { if (!releaseApiDone) { releaseApiDone = true; return origFetch(url, init); @@ -545,7 +558,7 @@ describe("performUpgrade — download path", () => { }); } return new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }); - }) as typeof fetch; + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (Bun as any).write = async () => 3; diff --git a/tsconfig.json b/tsconfig.json index c8dfd89..124e2bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ESNext"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", From 0d424f7841d4be1e5cfe8298438efc55bd061f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Wed, 28 Oct 2026 14:18:32 +0100 Subject: [PATCH 2/2] Address review: badge release fallback and fork-safe Lighthouse comment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/instructions/release.instructions.md | 3 ++- .github/skills/release.md | 2 +- .github/workflows/lighthouse.yaml | 4 ++++ docs/.vitepress/config.mts | 9 ++++++++- docs/.vitepress/theme/VersionBadge.vue | 5 ++++- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md index 08fa31b..4d06f1d 100644 --- a/.github/instructions/release.instructions.md +++ b/.github/instructions/release.instructions.md @@ -117,7 +117,8 @@ bun run build.ts # binary compiles - [ ] `bun run docs:build` succeeds locally (spot-check the new blog entry) - The **version badge** in the hero (`VersionBadge.vue`) reads `version` from `package.json` at build time via `vite.define` — no manual update needed. It auto-points to the new blog post - slug (`release-vX-Y-Z`) derived from the version. Verify it shows the new version after deploy. + slug (`release-vX-Y-Z`) derived from the version, or to the GitHub Release page when no blog + post exists (e.g. patch releases). Verify it shows the new version after deploy. - [ ] `CHANGELOG.md` has no `_pending_` entries - [ ] For **major** releases: versioned docs snapshot available at `/github-code-search/vX/` diff --git a/.github/skills/release.md b/.github/skills/release.md index 7c5bd3b..15e0329 100644 --- a/.github/skills/release.md +++ b/.github/skills/release.md @@ -77,7 +77,7 @@ Pushing `vX.Y.Z` triggers the release pipeline automatically: ## Version badge -`VersionBadge.vue` reads `version` from `package.json` at build time via `vite.define`. It auto-derives the blog post slug (`release-vX-Y-Z`) from the version — no manual update needed. Verify it shows correctly after the docs deploy (~5 min after tag push). +`VersionBadge.vue` reads `version` from `package.json` at build time via `vite.define`. It auto-derives the blog post slug (`release-vX-Y-Z`) from the version and falls back to the GitHub Release page when that post does not exist (optional for patch releases) — no manual update needed. Verify it shows correctly after the docs deploy (~5 min after tag push). --- diff --git a/.github/workflows/lighthouse.yaml b/.github/workflows/lighthouse.yaml index c8d623f..9fdd651 100644 --- a/.github/workflows/lighthouse.yaml +++ b/.github/workflows/lighthouse.yaml @@ -101,8 +101,12 @@ jobs: # Uses marocchino/sticky-pull-request-comment which upserts the comment # (one create, then updates on every subsequent push to the PR). # Reads the report written by the previous step via the `path:` input. + # continue-on-error: fork PRs get a read-only GITHUB_TOKEN, so posting the + # comment can fail even when the audit passes. The job summary and the + # "Fail if LHCI assertions failed" step below still reflect the result. - name: Post / update Lighthouse report on PR if: github.event_name == 'pull_request' + continue-on-error: true uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: lighthouse-ci diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4afdf6a..6d34789 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,11 +1,17 @@ import { defineConfig } from "vitepress"; -import { readdirSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import versionsData from "../public/versions.json"; import pkg from "../../package.json"; const latestVersion: string = pkg.version; const latestBlogSlug = `release-v${latestVersion.replace(/\./g, "-")}`; +// Patch releases may ship without a blog post: fall back to the GitHub Release page. +const latestReleaseUrl = existsSync( + fileURLToPath(new URL(`../blog/${latestBlogSlug}.md`, import.meta.url)), +) + ? null + : `https://github.com/fulll/github-code-search/releases/tag/v${latestVersion}`; // ─── Semantic version helpers for blog sidebar sort ─────────────────────────── function parseBlogVersion(filename: string): number[] { @@ -177,6 +183,7 @@ export default defineConfig({ // Consumed by docs/.vitepress/theme/VersionBadge.vue — auto-updates on docs:build __LATEST_VERSION__: JSON.stringify(latestVersion), __LATEST_BLOG_SLUG__: JSON.stringify(latestBlogSlug), + __LATEST_RELEASE_URL__: JSON.stringify(latestReleaseUrl), }, plugins: [ { diff --git a/docs/.vitepress/theme/VersionBadge.vue b/docs/.vitepress/theme/VersionBadge.vue index a80b208..7a8c7af 100644 --- a/docs/.vitepress/theme/VersionBadge.vue +++ b/docs/.vitepress/theme/VersionBadge.vue @@ -15,12 +15,15 @@ import { withBase } from "vitepress"; * __LATEST_VERSION__ and __LATEST_BLOG_SLUG__ are replaced at build/dev time * by vite.define in docs/.vitepress/config.mts, reading from package.json. * When docs:build is triggered after a release tag, the badge auto-updates. + * __LATEST_RELEASE_URL__ is set (GitHub Release page) only when the version + * has no blog post (optional for patch releases); otherwise it is null. */ declare const __LATEST_VERSION__: string; declare const __LATEST_BLOG_SLUG__: string; +declare const __LATEST_RELEASE_URL__: string | null; const version = __LATEST_VERSION__; -const releaseLink = withBase(`/blog/${__LATEST_BLOG_SLUG__}`); +const releaseLink = __LATEST_RELEASE_URL__ ?? withBase(`/blog/${__LATEST_BLOG_SLUG__}`);