diff --git a/.claude/rules/bun-native-apis.md b/.claude/rules/bun-native-apis.md new file mode 100644 index 0000000..39def66 --- /dev/null +++ b/.claude/rules/bun-native-apis.md @@ -0,0 +1,93 @@ +--- +paths: ['src/**/*.ts'] +globs: ['src/**/*.ts'] +--- + +# Bun-native APIs + +This project runs on Bun. Reach for `Bun.*` globals before the Node equivalent. Training-data bias makes the Node API the default suggestion; this rule lists the cases where the Bun shape is materially shorter or more idiomatic and should win. + +For anything not covered below, the canonical reference is — it concatenates Bun's full docs into a single markdown file. + +## File system reads + +`Bun.file(path).text() / .json() / .bytes()` instead of `readFileSync` + `JSON.parse`. Reads are lazy: no I/O until the accessor is awaited. + +```ts +// Good +const content = await Bun.file(argPath).text(); +const config = await Bun.file(configPath).json(); + +// Avoid +const content = readFileSync(argPath, 'utf8'); +const config = JSON.parse(readFileSync(configPath, 'utf8')) as Config; +``` + +## File system writes + +`Bun.write(path, data)` instead of `writeFileSync`. Auto-creates parent directories, accepts strings, `Bun.file` handles, `ArrayBuffer`, or `Response`. + +```ts +// Good +await Bun.write(outPath, rendered); + +// Avoid +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, rendered, 'utf8'); +``` + +## Globbing & directory walks + +`new Bun.Glob(pattern).scanSync(root)` (or async `scan`) instead of `readdirSync({ recursive: true })` and instead of any hand-rolled recursive `walk()`. The recursive `readdirSync` flag works but doesn't pattern-filter; rolling a walk yourself is the most common Node carry-over. + +```ts +// Good +const files = Array.from(new Bun.Glob('**/*.md').scanSync(rootDir)).sort(); + +// Avoid +function walk(dir: string): string[] { + /* recursive readdirSync... */ +} +const files = walk(rootDir).filter((p) => p.endsWith('.md')); +``` + +## Subprocesses + +`Bun.spawn` / `Bun.spawnSync` instead of `child_process.spawn` / `exec`. Use `Bun.which` instead of probing `$PATH` manually. + +```ts +// Good +const proc = Bun.spawn({ cmd: [bin, ...args], stdout: 'pipe', stderr: 'pipe' }); +const stdout = await new Response(proc.stdout).text(); +const exitCode = await proc.exited; + +// Avoid +const { stdout } = await promisify(execFile)(bin, args); +``` + +## HTTP servers + +`Bun.serve` instead of Express, Fastify, or `node:http`. + +## SQLite + +`bun:sqlite` instead of `better-sqlite3` or `node:sqlite`. + +## Compile-time evaluation + +Two related Bun-only import attribute forms; reach for them whenever the value is fixed at bundle time. + +- `with { type: 'text' }` to embed a file's contents as a string literal at build. +- `with { type: 'macro' }` to evaluate a function at build time and inline its return value. Right shape for version stamps, fixture generation, build-time enumeration of files, and anything else that's a pure function of the source tree. + +If you catch yourself writing a `readFileSync(...)` or a constant lookup at the top of a module to compute something the bundler could compute once, it's a macro. + +## What not to use + +- `fs.readdirSync(..., { recursive: true })` walks → use `Bun.Glob`. +- Hand-rolled recursive `walk()` helpers → use `Bun.Glob`. +- `child_process.exec` / `execFile` → use `Bun.spawn`. +- `node:http` for new servers → use `Bun.serve`. +- `better-sqlite3` / `node:sqlite` → use `bun:sqlite`. + +`readFileSync` and `writeFileSync` are not banned outright — they're fine in a tight synchronous loop where `await` would force a redundant restructure — but `Bun.file` / `Bun.write` should be the default. diff --git a/.claude/rules/bun-testing.md b/.claude/rules/bun-testing.md new file mode 100644 index 0000000..4d957f0 --- /dev/null +++ b/.claude/rules/bun-testing.md @@ -0,0 +1,47 @@ +--- +paths: ['src/**/*.test.ts'] +globs: ['src/**/*.test.ts'] +--- + +# Bun testing conventions + +Conventions specific to `bun:test` that are non-obvious and have bitten us. + +## `.rejects` / `.resolves` matchers are synchronous — don't `await`, don't `async`, don't `nextTick` + +`expect(promise).rejects.toX()` and `expect(promise).resolves.toX()` **block synchronously at runtime** via `globalThis.bunVM().waitForPromise(...)`. `bun-types` types them as returning `void` — the types are wrong and [upstream closed the fix as "not planned"](https://github.com/oven-sh/bun/issues/15457). + +**Correct pattern:** + +```ts +it('rejects on X', () => { + expect(fn(...)).rejects.toBeInstanceOf(SomeError); + expect(io.stderr.text()).toContain('expected message'); +}); +``` + +No `async`, no `await`, no manual `nextTick` flush. + +**Why:** adapting Jest habits to Bun produces an antagonistic lint/TS pair: + +- With `await expect(...).rejects.toX()` → TS80007 "`await` has no effect on the type of this expression" (the matcher is typed `void`). +- Without `await` → `@typescript-eslint/require-await` flags `async` as pointless. + +Dropping both resolves both. The matcher still works — it already blocks the thread. + +**`nextTick` is also unnecessary.** Some older tests have: + +```ts +// CARGO-CULT — DO NOT COPY +it('rejects on X', async () => { + expect(fn(...)).rejects.toBeInstanceOf(SomeError); + await nextTick(); + expect(readErrorLogs(logs).some(...)).toBe(true); +}); +``` + +That helper is a leftover from Jest patterns. `waitForPromise` already settled the promise, and stream-backed reads are ready immediately on the next line. + +**Keep `async` only when the body has a real `await`** — e.g. `await runHandler(ctx)` on a happy path, or async fixture setup. Don't keep it "just in case." + +**Don't try to patch the types.** `Matchers.resolves` / `Matchers.rejects` are typed as property accessors on an interface; TS declaration merging can only add, not override. A local wrapper helper would work, but Bun's synchronous behavior makes it unnecessary. diff --git a/.claude/rules/context-interfaces-and-fakes.md b/.claude/rules/context-interfaces-and-fakes.md new file mode 100644 index 0000000..5f79be4 --- /dev/null +++ b/.claude/rules/context-interfaces-and-fakes.md @@ -0,0 +1,14 @@ +--- +paths: ['src/**/*.ts'] +globs: ['src/**/*.ts'] +--- + +# Context Interfaces and Fakes + +- **Injectable dependencies should have a public contract and separate implementations.** When adding a dependency that performs I/O, external API calls, filesystem access, or other side effects, define a narrow public interface for the behavior callers depend on. Callers accept the interface; production code wires up the real implementation. + +- **Name the production implementation `XxxImpl`.** The real implementation implements the public interface and carries the environment-specific behavior. Consumer parameters and struct fields should be typed as the interface, not the concrete implementation. + +- **Name the test implementation `FakeXxx`.** Shared test helpers implement the same public interface with deterministic, inspectable behavior. Keep fakes focused on the interface contract so tests can swap them in without casts, module mocking, or duplicating production implementation details. + +- **Pass dependencies as plain function arguments or a small `Context` object.** Don't reach for module-level mutable singletons or globals; they make tests order-dependent and force module mocking. Collectors and other long-running operations should accept their client/IO dependencies as arguments. diff --git a/.claude/rules/error-handling-neverthrow.md b/.claude/rules/error-handling-neverthrow.md new file mode 100644 index 0000000..8a5480d --- /dev/null +++ b/.claude/rules/error-handling-neverthrow.md @@ -0,0 +1,34 @@ +--- +paths: ['src/**/*.ts', 'src/**/*.tsx'] +globs: ['src/**/*.ts', 'src/**/*.tsx'] +--- + +# Error Handling Neverthrow + +- **Use neverthrow for safe wrappers around fallible I/O.** Wrap operations that can throw (file reads, JSON parsing, API calls) in `Result` types via `fromThrowable` for sync work and `ResultAsync.fromPromise` for promises. This keeps error handling explicit and chainable without try/catch blocks scattered through business logic. + + **Good:** + + ```typescript + import { fromThrowable, Result, ResultAsync } from 'neverthrow'; + import { toError } from '#src/errors.ts'; + + const safeRead = fromThrowable((path: string) => readFileSync(path, 'utf8').trim()); + const existing = safeRead(path).unwrapOr(''); + + export const safeJsonParse = Result.fromThrowable((text: string) => JSON.parse(text) as unknown, toError); + + function fetchUser(id: string): ResultAsync { + return ResultAsync.fromPromise(client.users.get(id), toFetchError); + } + ``` + +- **Use discriminated-union errors at module boundaries.** Each module that produces errors defines a small `XxxError` union with a `kind` discriminator. Downstream callers narrow by `kind` instead of `instanceof`. Keep the union flat — don't nest causes inside other unions. + +- **Collectors and other long-running operations chain with `.andThen` / `.map` / `.mapErr`.** They never `throw`, never wrap a bare `await` over a fallible Promise, and never `try/catch`. If a sub-call returns `ResultAsync`, chain it; don't unwrap mid-flight. + +- **The CLI entrypoint is the only place that unwraps.** It pattern-matches on the error type to format a human message, then sets a non-zero exit code. Do not use `unwrapOr` inside business logic when the fallback would silently hide a real bug — only at presentation boundaries where partial data is the intentional behavior. + +- **Partial-failure boundaries are explicit.** When a per-item operation may fail and the run should continue (e.g. per-repo crawl where one repo's 403 shouldn't kill the report), the boundary that accepts partial failure handles the `Err` explicitly: log it, push a warning into the slice, and proceed with the successful items. Inside each per-item collector, no try/catch. + +- **Tests assert on `Result` shapes.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result`. diff --git a/.claude/rules/testing-patterns.md b/.claude/rules/testing-patterns.md new file mode 100644 index 0000000..3311614 --- /dev/null +++ b/.claude/rules/testing-patterns.md @@ -0,0 +1,35 @@ +--- +paths: ['src/**/*.test.ts'] +globs: ['src/**/*.test.ts'] +--- + +# Testing Patterns + +- **Test factories use Fishery with `.build()` invocations.** Test data is constructed via Fishery `Factory.define()` factories, never hand-rolled `createXxx()` helpers with inline object literals. Factories live next to the type they produce (e.g. `src//testFactories.ts`). Tests call `.build({ overrides })` to get fixture data. + + **Good:** + + ```typescript + import { Factory } from 'fishery'; + import type { RepoMeta } from '#src/types.ts'; + + export const repoMeta = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + visibility: 'private', + archived: false, + defaultBranch: 'main', + primaryLanguage: 'TypeScript', + pushedAt: '2026-01-01T00:00:00Z', + dependabotSecurityUpdates: true, + })); + + // In test: + const repo = repoMeta.build({ archived: true }); + ``` + +- **Prefer `toMatchObject` for structured payload assertions.** When a test verifies several fields on the same object or nested payload, use one `expect(value).toMatchObject({ ... })` instead of a run of field-by-field assertions. Keep separate assertions for orthogonal behavior, clearer failure messages, or values that need a specialized matcher. + +- **Use a shared deferred-promise helper; do not hand-roll it.** Tests that need manual promise resolution should import a single `createDeferred` helper. Do not recreate local `Deferred` types or `new Promise` wrappers in individual test files. + +- **Assert on `Result` shapes, not on thrown errors.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result` (see `error-handling-neverthrow.md`). diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml new file mode 100644 index 0000000..21c24fd --- /dev/null +++ b/.github/actions/bootstrap/action.yml @@ -0,0 +1,52 @@ +name: Bootstrap +description: Install Bun via asdf and project dependencies + +runs: + using: 'composite' + steps: + - name: Setup asdf + uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + with: + asdf_version: '0.18.0' + + - name: Restore asdf cache + id: asdf-cache-restore + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.tool-versions') }} + path: | + ~/.asdf/installs + ~/.asdf/plugins + ~/.asdf/shims + restore-keys: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}- + + - name: Install asdf plugins + if: ${{ steps.asdf-cache-restore.outputs.cache-hit != 'true' }} + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + with: + asdf_version: '0.18.0' + + - name: Reshim asdf + shell: bash + run: asdf reshim + + - name: Save asdf cache + if: always() && steps.asdf-cache-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: ${{ steps.asdf-cache-restore.outputs.cache-primary-key }} + path: | + ~/.asdf/installs + ~/.asdf/plugins + ~/.asdf/shims + + - name: Restore Bun install cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: bun-install-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} + path: ~/.bun/install/cache + restore-keys: bun-install-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..bb5e365 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,16 @@ +name: Main Branch + +on: + push: + branches: [main] + +concurrency: + group: main + cancel-in-progress: false + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + permissions: + contents: read diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..7916dc3 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,15 @@ +name: PR + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + permissions: + contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fbcd39c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: Test + +on: + workflow_call: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/bootstrap + - name: Check formatting + run: bun run format:check + - name: Restore ESLint cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: .eslintcache + key: eslint-${{ runner.os }}-${{ github.sha }} + restore-keys: eslint-${{ runner.os }}- + - name: Run ESLint + run: bun run lint + - name: Typecheck + run: bun run typecheck + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/bootstrap + - name: Get Playwright version + id: playwright-version + run: echo "version=$(jq -r '.devDependencies.playwright' package.json)" >> "$GITHUB_OUTPUT" + - name: Get Ubuntu version + id: ubuntu-version + run: echo "version=$(lsb_release -rs)" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /home/runner/.cache/ms-playwright + key: ${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers-${{ steps.playwright-version.outputs.version }} + restore-keys: | + ${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers- + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + - name: Run tests + run: bun run test diff --git a/.gitignore b/.gitignore index a14702c..e2eec53 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ out dist *.tgz +# dev-only fixture for the report web app +src/report/web/data/fixture.json + # code coverage coverage *.lcov diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..ea5a55b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +bunx lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..d6df897 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,9 @@ +bun run lint +bun run typecheck + +# AI agent only — set IS_AI_AGENT=1 to enable +if [ "$IS_AI_AGENT" = "1" ]; then + bun test +else + echo "tests skipped (set IS_AI_AGENT=1 to run on pre-push)" +fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1000aef --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +coverage/ +claude-tmp/ +bun.lock +.husky/_/ diff --git a/CLAUDE.md b/CLAUDE.md index ebda995..bd06cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ --- description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +globs: '*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json' alwaysApply: false --- @@ -109,3 +109,9 @@ bun --hot ./index.ts ``` For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. + +## Conventions + +Team rules under `.claude/rules/` apply to files under `src/`. Read them before editing matching files: + +- [Error Handling Neverthrow](.claude/rules/error-handling-neverthrow.md) — no try/catch in business logic; use `Result` / `ResultAsync` diff --git a/README.md b/README.md index ebe2b94..67edbb5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,113 @@ # patchwave-analysis -To install dependencies: +A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. Runs in your environment and writes a self-contained HTML report plus a raw-data zip. No data leaves your network unless you choose to share the generated artifacts. -```bash +## What it tells you + +Given an org or user, the report covers: + +- **Org overview** — repo count, public/private split, language mix, branch-protection coverage, active human committers +- **Dependabot coverage** — percent of repos with config, security-updates status, ecosystems, Node package manager split +- **PR backlog** — open vs merged vs closed in window, age buckets, bump-type split, time-to-merge p50/p90, CI status mix, dev-only-dep share +- **Stalled-PR signals** — repos at Dependabot's PR cap, repos with config but no recent PRs, reverts in window +- **People** — top human mergers and reviewers +- **Toil cost** — annualized engineer-time cost with browser-adjustable assumptions +- **CVE exposure** — open Dependabot security alerts by severity, oldest open Critical/High +- **Automation upside** — projected savings for common auto-merge rates + +## Run it + +You need a GitHub token with `repo` and `read:org` scopes. For CVE metrics, add `security_events`. + +### With Bun installed + +```sh +bunx patchwave-analysis@latest +``` + +### With the gh CLI installed + +```sh +bunx patchwave-analysis@latest +# auth is auto-resolved via `gh auth token` +``` + +Token resolution order: `GITHUB_TOKEN` env var, then `GH_TOKEN` env var, then `gh auth token`. + +### From source + +```sh +git clone https://github.com/contextbridge/patchwave-analysis +cd patchwave-analysis bun install +bun run build:report-web +bun run src/index.ts +``` + +For local report UI development: + +```sh +bun run dev:report-web ``` -To run: +## Options -```bash -bun run index.ts +```text +--window rolling time window (default 90d, e.g. 30d, 12w) +--out output basename; writes .html and .zip + (default ./patchwave-report) +--include comma-separated repo names to include +--exclude comma-separated repo names to exclude +--help show this help ``` -This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. +## Output + +Each run writes two siblings next to `--out`: + +- **`.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file. +- **`.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge. + +The zip contains: + +```text +patchwave-report.html — interactive HTML report +README.txt — what's in the bundle +data/meta.json — CLI version, target, window, run options, top-level counts +data/aggregated.json — rolled-up metrics that drive the report +data/repos.json — repo metadata +data/languages.json — per-repo language byte counts +data/dependabot-config.json — per-repo Dependabot config and ecosystems +data/dependabot-prs.json — Dependabot PRs in the window (state, checks, reviewers) +data/cve.json — Dependabot security alert slices +data/reverts.json — revert commits detected in the window +data/branch-protection.json — default-branch protection slices +data/contributors.json — active human committers per repo +data/warnings.json — per-collector warnings suppressed during the crawl +``` + +Nothing in the report or bundle leaves your machine unless you choose to share it. The archive does not include tokens, secrets, or repository file contents. + +## What it does not do + +- It does not upload the report or any GitHub data. It only reads from `api.github.com`. Filesystem writes are limited to the `.html` / `.zip` pair under `--out` and a one-time anonymous-id file (see Telemetry). +- It does not keep a Markdown compatibility report. +- It does not auto-update. + +## Telemetry + +The CLI sends anonymous product analytics (PostHog) to help us understand how it's used. A random UUID is stored at `$XDG_CONFIG_HOME/contextbridge/anonymous_id` or `~/.config/contextbridge/anonymous_id` and shared across contextbridge tools. **Org names, repo names, tokens, and report contents are never sent** — only event counts and timings. + +Events captured: `run_started` (window size, whether include/exclude was used), `run_completed` (repo/PR/warning counts and duration), `run_failed` (error kind and duration). + +Opt out by setting any of: + +- `DO_NOT_TRACK=1` +- `CONTEXTBRIDGE_TELEMETRY_DISABLED=1` +- `CI=1` + +When disabled, no anonymous-id file is created and no events are sent. + +## License + +MIT. diff --git a/bun.lock b/bun.lock index 0829e9f..a59e995 100644 --- a/bun.lock +++ b/bun.lock @@ -4,23 +4,884 @@ "workspaces": { "": { "name": "patchwave-analysis", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "@octokit/graphql": "^9.0.3", + "@octokit/plugin-retry": "^8.1.0", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/rest": "^22.0.1", + "fflate": "^0.8.3", + "neverthrow": "^8.2.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "posthog-node": "^5.35.1", + "react": "^19", + "react-dom": "^19", + "semver": "^7.8.1", + "zod": "^4.4.3", + }, "devDependencies": { + "@contextbridge-ai/eslint-config": "^0.0.0", + "@contextbridge-ai/prettier-config": "^0.0.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/semver": "^7.7.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser-playwright": "^4.0.18", + "bun-plugin-tailwind": "^0.1.2", + "eslint": "^10.4.0", + "fishery": "^2.4.0", + "globals": "^17.6.0", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "playwright": "^1.58.2", + "prettier": "^3.8.3", + "tailwindcss": "^4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vite": "^7.2.4", + "vitest": "^4.0.18", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + + "@contextbridge-ai/eslint-config": ["@contextbridge-ai/eslint-config@0.0.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-unused-imports": "^4.4.1", "typescript-eslint": "^8.59.1" }, "peerDependencies": { "eslint": "^10.0.0", "typescript": "^6.0.0" } }, "sha512-5+1HATOHYQNN9xR4wYiRUZ0Qb+km9YdMyr94sWbkVGApCc9zb7BQDsVHSEFjtkHq28NQ9DIRgPeuwCjxFjkngg=="], + + "@contextbridge-ai/prettier-config": ["@contextbridge-ai/prettier-config@0.0.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-hW6TZzP3PT9Mcj84xdfduwEWW+j/K6rzc5dZqdx9lOzfz7HGRWI26rWG9snNkv49REyGkLW2QvSA5C60utJiiw=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + + "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.0", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg=="], + + "@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + + "@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@posthog/core": ["@posthog/core@1.29.9", "", { "dependencies": { "@posthog/types": "1.376.0" } }, "sha512-DjvuIyBZ2Z/gBhtZlITlM2D8PlnMsHSQ1D78dbUYoVsgGguvanpJTobZObjLlFkybyvfZFYkpoJkFNI/2Pw4IQ=="], + + "@posthog/types": ["@posthog/types@1.376.0", "", {}, "sha512-gbFfxCuZDs/D4QZMwdE+smD1jsuqgGpS6yKGHZZ19foxMy8RYHsU1E47iG1b88n/uN02fAabLibVwuxLtq8juw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.12.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.12.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.12.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA=="], + + "@unrs/resolver-binding-linux-loong64-gnu": ["@unrs/resolver-binding-linux-loong64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q=="], + + "@unrs/resolver-binding-linux-loong64-musl": ["@unrs/resolver-binding-linux-loong64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.12.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.12.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A=="], + + "@unrs/resolver-binding-openharmony-arm64": ["@unrs/resolver-binding-openharmony-arm64@1.12.2", "", { "os": "none", "cpu": "arm64" }, "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.12.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.12.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.12.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@vitest/browser": ["@vitest/browser@4.1.7", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.7" } }, "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.7", "", { "dependencies": { "@vitest/browser": "4.1.7", "@vitest/mocker": "4.1.7", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.7" } }, "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw=="], + + "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], + + "@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="], + + "@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="], + + "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], + + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + + "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], + + "eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="], + + "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "fishery": ["fishery@2.4.0", "", { "dependencies": { "lodash.mergewith": "^4.6.2" } }, "sha512-QgeTlvgNhVGuMztrfAhlSIBs3rD3l9RMjl9I15yb/lnrx3njrOhvegr2L3LWdqvXwYfQjdQGpglyAfHH2J8DRA=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="], + + "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.mergewith": ["lodash.mergewith@4.6.2", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "posthog-node": ["posthog-node@5.35.1", "", { "dependencies": { "@posthog/core": "1.29.9" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-F9S3pEIYfGEVjLYIFHKaqfTIhn5IpS02Dkp7C/f1rqr4Z67Iqbt4jbKO8raWsT0veEI3rUp+DKuXLW1hN07FQA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..de249f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +plugins = ["bun-plugin-tailwind"] diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..9497ad6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,66 @@ +import baseConfig from '@contextbridge-ai/eslint-config/base'; +import { defineConfig } from 'eslint/config'; + +// Flat-config array-valued rules (like no-restricted-syntax) are REPLACED, not +// merged, when a later matching block sets the same rule. Keep the shared +// selectors here so scoped blocks can include them alongside their own. +const dateRestrictedSelectors = [ + { + selector: 'NewExpression[callee.name="Date"]', + message: 'Use Temporal from ./src/time.ts instead of Date.', + }, + { + selector: 'CallExpression[callee.name="Date"]', + message: 'Use Temporal from ./src/time.ts instead of Date.', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="now"]', + message: 'Use Temporal from ./src/time.ts instead of Date.now().', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="parse"]', + message: 'Use Temporal from ./src/time.ts instead of Date.parse().', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="UTC"]', + message: 'Use Temporal from ./src/time.ts instead of Date.UTC().', + }, +]; + +const consoleRestrictedSelector = { + selector: "CallExpression[callee.object.name='console']", + message: + 'Do not use console.* — use ctx.logger for diagnostics (writes to stderr) and ctx.io.writeStdout for business output.', +}; + +const processRestrictedProperties = [ + { + object: 'process', + property: 'stdout', + message: 'Use ctx.io.writeStdout (see DI conventions in .claude/rules).', + }, + { + object: 'process', + property: 'stderr', + message: 'Use ctx.logger or ctx.io.writeStderr (see DI conventions in .claude/rules).', + }, +]; + +export default defineConfig( + ...baseConfig, + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'claude-tmp/**', 'bun.lock'], + }, + { + rules: { + 'no-restricted-syntax': ['error', ...dateRestrictedSelectors], + }, + }, + { + files: ['src/**/*.ts'], + rules: { + 'no-restricted-syntax': ['error', ...dateRestrictedSelectors, consoleRestrictedSelector], + 'no-restricted-properties': ['error', ...processRestrictedProperties], + }, + }, +); diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json index db2545a..fff587f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,84 @@ { "name": "patchwave-analysis", - "module": "index.ts", + "version": "0.0.1", + "description": "Diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org.", "type": "module", - "private": true, + "bin": { + "patchwave-analysis": "./src/index.ts" + }, + "files": [ + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/testFactories.ts", + "!src/testHelpers", + "!src/report/web", + "dist/report-web/index.html", + "README.md" + ], + "scripts": { + "start": "bun run build:report-web && bun run src/index.ts", + "test": "bun run test:unit && bun run test:browser", + "test:unit": "bun run build:report-web && bun test ./src/*.test.ts ./src/**/*.test.ts", + "test:browser": "vitest run", + "typecheck": "bun run build:report-web && tsc --noEmit", + "lint": "eslint . --max-warnings 0 --cache", + "lint:fix": "eslint . --fix --cache", + "format": "prettier --write \"**/*.{ts,tsx,md,json,yaml,yml}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,md,json,yaml,yml}\"", + "prepare": "husky", + "prepack": "bun run build:report-web", + "build:report-web": "bun run scripts/build-report-web.ts", + "dev:report-web": "bun ./src/report/web/index.dev.html", + "build:darwin-arm64": "bun run build:report-web && bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-arm64", + "build:darwin-x64": "bun run build:report-web && bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-x64", + "build:linux-x64": "bun run build:report-web && bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile dist/patchwave-analysis-linux-x64" + }, + "prettier": "@contextbridge-ai/prettier-config", + "lint-staged": { + "*.{ts,tsx}": "eslint --fix --no-warn-ignored", + "*": "prettier --write --ignore-unknown" + }, "devDependencies": { - "@types/bun": "latest" + "@contextbridge-ai/eslint-config": "^0.0.0", + "@contextbridge-ai/prettier-config": "^0.0.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser-playwright": "^4.0.18", + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/semver": "^7.7.1", + "bun-plugin-tailwind": "^0.1.2", + "eslint": "^10.4.0", + "fishery": "^2.4.0", + "globals": "^17.6.0", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "prettier": "^3.8.3", + "playwright": "^1.58.2", + "tailwindcss": "^4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vite": "^7.2.4", + "vitest": "^4.0.18" }, - "peerDependencies": { - "typescript": "^5" + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "@octokit/graphql": "^9.0.3", + "@octokit/plugin-retry": "^8.1.0", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/rest": "^22.0.1", + "fflate": "^0.8.3", + "neverthrow": "^8.2.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "posthog-node": "^5.35.1", + "react": "^19", + "react-dom": "^19", + "semver": "^7.8.1", + "zod": "^4.4.3" } } diff --git a/scripts/build-report-web.ts b/scripts/build-report-web.ts new file mode 100644 index 0000000..9699814 --- /dev/null +++ b/scripts/build-report-web.ts @@ -0,0 +1,30 @@ +import tailwindPlugin from 'bun-plugin-tailwind'; + +const result = await Bun.build({ + entrypoints: ['./src/report/web/index.html'], + compile: true, + target: 'browser', + outdir: './dist/report-web', + minify: true, + define: { + 'process.env.NODE_ENV': '"production"', + }, + plugins: [tailwindPlugin], +}); + +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} + +const out = result.outputs[0]?.path ?? 'dist/report-web/index.html'; +const html = await Bun.file('./dist/report-web/index.html').text(); +if (!html.includes('__PATCHWAVE_DATA__')) { + console.error('dist/report-web/index.html is missing __PATCHWAVE_DATA__'); + process.exit(1); +} +if (html.includes('jsxDEV')) { + console.error('dist/report-web/index.html contains React development markers'); + process.exit(1); +} +console.log(`built ${out}`); diff --git a/src/Analytics.test.ts b/src/Analytics.test.ts new file mode 100644 index 0000000..14701e5 --- /dev/null +++ b/src/Analytics.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test'; +import { AnalyticsImpl, NoopAnalytics, type PostHogClient } from './Analytics.ts'; + +interface RecordedIdentify { + readonly distinctId: string | undefined; + readonly properties?: Record; +} + +interface RecordedCapture { + readonly distinctId: string | undefined; + readonly event: string; + readonly properties?: Record; +} + +interface FakeClient { + readonly client: PostHogClient; + readonly identify: RecordedIdentify[]; + readonly capture: RecordedCapture[]; +} + +function createFakeClient(): FakeClient { + const identify: RecordedIdentify[] = []; + const capture: RecordedCapture[] = []; + return { + identify, + capture, + client: { + identify: (input) => { + identify.push({ distinctId: input.distinctId, properties: input.properties }); + }, + capture: (input) => { + capture.push({ distinctId: input.distinctId, event: input.event, properties: input.properties }); + }, + flush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }, + }; +} + +describe('AnalyticsImpl', () => { + test('stamps surface and version on identify and capture', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: fake.client }); + + a.identify('user-1'); + a.capture('run_started', { foo: 'bar' }); + + expect(fake.identify[0]).toMatchObject({ + distinctId: 'user-1', + properties: { pw_surface: 'cli', pw_version: '0.0.1' }, + }); + expect(fake.capture[0]).toMatchObject({ + distinctId: 'user-1', + event: 'run_started', + properties: { pw_surface: 'cli', pw_version: '0.0.1', foo: 'bar' }, + }); + }); + + test('register merges into subsequent calls', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: fake.client }); + + a.register({ pw_command: 'run' }); + a.capture('event'); + + expect(fake.capture[0]?.properties).toMatchObject({ pw_command: 'run' }); + }); + + test('capture always uses the constructor distinctId', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'original', version: '0.0.1', client: fake.client }); + + a.identify('different'); + a.capture('event'); + + expect(fake.capture[0]?.distinctId).toBe('original'); + }); + + test('swallows thrown client errors so telemetry never breaks the CLI', () => { + const throwing: PostHogClient = { + identify: () => { + throw new Error('boom'); + }, + capture: () => { + throw new Error('boom'); + }, + flush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }; + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: throwing }); + + expect(() => a.identify('user-1')).not.toThrow(); + expect(() => a.capture('event')).not.toThrow(); + }); + + test('flush and shutdown swallow rejected promises', () => { + const rejecting: PostHogClient = { + identify: () => {}, + capture: () => {}, + flush: () => Promise.reject(new Error('flush boom')), + shutdown: () => Promise.reject(new Error('shutdown boom')), + }; + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: rejecting }); + + expect(a.flush()).resolves.toBeUndefined(); + expect(a.shutdown()).resolves.toBeUndefined(); + }); +}); + +describe('NoopAnalytics', () => { + test('does nothing observable and never throws', () => { + const a = new NoopAnalytics(); + expect(() => a.identify('x')).not.toThrow(); + expect(() => a.capture('e')).not.toThrow(); + expect(() => a.register({ k: 'v' })).not.toThrow(); + expect(a.flush()).resolves.toBeUndefined(); + expect(a.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/src/Analytics.ts b/src/Analytics.ts new file mode 100644 index 0000000..ee02845 --- /dev/null +++ b/src/Analytics.ts @@ -0,0 +1,92 @@ +import { ResultAsync, fromThrowable } from 'neverthrow'; +import { PostHog } from 'posthog-node'; + +export interface Analytics { + identify(distinctId: string, properties?: Record): void; + capture(event: string, properties?: Record): void; + register(properties: Record): void; + flush(): Promise; + shutdown(): Promise; +} + +export type PostHogClient = Pick; + +const POSTHOG_KEY = 'phc_nDFXB87pWC9QEJHLjtb38BbjboXaxnzkcTJ8SN3Dr62j'; +const POSTHOG_HOST = 'https://us.i.posthog.com'; + +export interface AnalyticsImplOptions { + readonly distinctId: string; + readonly version: string; + readonly client?: PostHogClient; +} + +export class AnalyticsImpl implements Analytics { + readonly #distinctId: string; + readonly #client: PostHogClient; + readonly #superProperties: Record; + readonly #safeIdentify: (input: Parameters[0]) => void; + readonly #safeCapture: (input: Parameters[0]) => void; + + constructor(options: AnalyticsImplOptions) { + this.#distinctId = options.distinctId; + this.#client = options.client ?? createDefaultClient(); + this.#superProperties = { + pw_surface: 'cli', + pw_version: options.version, + }; + // Wrap PostHog calls in neverthrow so a telemetry failure (network, bad + // payload) is explicit and can never escape into the CLI's control flow. + const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client)); + const safeCapture = fromThrowable(this.#client.capture.bind(this.#client)); + this.#safeIdentify = (input) => { + void safeIdentify(input); + }; + this.#safeCapture = (input) => { + void safeCapture(input); + }; + } + + identify(distinctId: string, properties?: Record): void { + this.#safeIdentify({ + distinctId, + properties: { ...this.#superProperties, ...properties }, + }); + } + + capture(event: string, properties?: Record): void { + this.#safeCapture({ + distinctId: this.#distinctId, + event, + properties: { ...this.#superProperties, ...properties }, + }); + } + + register(properties: Record): void { + Object.assign(this.#superProperties, properties); + } + + async flush(): Promise { + await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined); + } + + async shutdown(): Promise { + await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined); + } +} + +export class NoopAnalytics implements Analytics { + identify(_distinctId: string, _properties?: Record): void {} + capture(_event: string, _properties?: Record): void {} + register(_properties: Record): void {} + async flush(): Promise {} + async shutdown(): Promise {} +} + +function createDefaultClient(): PostHog { + return new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + // Short flush window for short-lived CLI processes; flushAt=1 sends eagerly. + flushAt: 1, + flushInterval: 1000, + }); +} diff --git a/src/BaseIo.ts b/src/BaseIo.ts new file mode 100644 index 0000000..36ce144 --- /dev/null +++ b/src/BaseIo.ts @@ -0,0 +1,42 @@ +import type { Writable } from 'node:stream'; + +export interface Writer extends Writable { + readonly isTTY?: boolean; +} + +export interface Io { + /** + * Raw stdout stream. Prefer `writeStdout(chunk)` — this field exists for + * library adapters and tests that need stream-level access. + */ + readonly stdout: Writer; + /** + * Raw stderr stream. Prefer `writeStderr(chunk)` — same rationale as stdout. + */ + readonly stderr: Writer; + writeStdout(chunk: string): void; + writeStderr(chunk: string): void; +} + +export interface BaseIoOptions { + readonly stdout: Writer; + readonly stderr: Writer; +} + +export abstract class BaseIo implements Io { + readonly stdout: Writer; + readonly stderr: Writer; + + protected constructor(options: BaseIoOptions) { + this.stdout = options.stdout; + this.stderr = options.stderr; + } + + writeStdout(chunk: string): void { + this.stdout.write(chunk); + } + + writeStderr(chunk: string): void { + this.stderr.write(chunk); + } +} diff --git a/src/Clock.ts b/src/Clock.ts new file mode 100644 index 0000000..13057c1 --- /dev/null +++ b/src/Clock.ts @@ -0,0 +1,11 @@ +import { type Instant, nowInstant } from './time.ts'; + +export interface Clock { + now(): Instant; +} + +export class ClockImpl implements Clock { + now(): Instant { + return nowInstant(); + } +} diff --git a/src/FileSystem.ts b/src/FileSystem.ts new file mode 100644 index 0000000..bfd432d --- /dev/null +++ b/src/FileSystem.ts @@ -0,0 +1,34 @@ +import { ResultAsync } from 'neverthrow'; +import { toError } from './errors.ts'; + +export type FsError = { kind: 'write-failed'; path: string; message: string }; + +export interface FileSystem { + writeTextFile(path: string, contents: string): ResultAsync; + writeBinaryFile(path: string, contents: Uint8Array): ResultAsync; +} + +export class FileSystemImpl implements FileSystem { + writeTextFile(path: string, contents: string): ResultAsync { + return this.write(path, contents); + } + + writeBinaryFile(path: string, contents: Uint8Array): ResultAsync { + return this.write(path, contents); + } + + private write(path: string, contents: string | Uint8Array): ResultAsync { + return ResultAsync.fromPromise( + Bun.write(path, contents).then(() => undefined), + (e): FsError => ({ + kind: 'write-failed', + path, + message: toError(e).message, + }), + ); + } +} + +export function formatFsError(err: FsError): string { + return `failed to write ${err.path}: ${err.message}`; +} diff --git a/src/IoImpl.ts b/src/IoImpl.ts new file mode 100644 index 0000000..9a195e7 --- /dev/null +++ b/src/IoImpl.ts @@ -0,0 +1,19 @@ +import { BaseIo, type Writer } from './BaseIo.ts'; + +export type { Io, Writer } from './BaseIo.ts'; + +export interface IoImplOptions { + readonly stdout?: Writer; + readonly stderr?: Writer; +} + +export class IoImpl extends BaseIo { + constructor(options: IoImplOptions = {}) { + // The one place that touches the real process streams — everything downstream + // receives them through ctx.io. + /* eslint-disable no-restricted-properties */ + const { stdout = process.stdout, stderr = process.stderr } = options; + /* eslint-enable no-restricted-properties */ + super({ stdout, stderr }); + } +} diff --git a/src/anonymousId.test.ts b/src/anonymousId.test.ts new file mode 100644 index 0000000..3c23bbc --- /dev/null +++ b/src/anonymousId.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { getOrCreateAnonymousId } from './anonymousId.ts'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'patchwave-anonid-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +test('creates a new UUID when the file is missing and persists it', () => { + const id = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + const persisted = readFileSync(join(dir, 'contextbridge', 'anonymous_id'), 'utf8').trim(); + expect(persisted).toBe(id); +}); + +test('returns the existing id on subsequent calls', () => { + const first = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + const second = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(second).toBe(first); +}); + +test('falls back to $HOME/.config when XDG_CONFIG_HOME is unset', () => { + const id = getOrCreateAnonymousId({ HOME: dir }); + expect(id.length).toBeGreaterThan(0); + const persisted = readFileSync(join(dir, '.config', 'contextbridge', 'anonymous_id'), 'utf8').trim(); + expect(persisted).toBe(id); +}); + +test('reads a pre-existing id file written by another tool', async () => { + await Bun.write(join(dir, 'contextbridge', 'anonymous_id'), 'preexisting-id\n'); + const id = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(id).toBe('preexisting-id'); +}); diff --git a/src/anonymousId.ts b/src/anonymousId.ts new file mode 100644 index 0000000..efebd89 --- /dev/null +++ b/src/anonymousId.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fromThrowable } from 'neverthrow'; + +const APP_DIR_NAME = 'contextbridge'; +const FILE_NAME = 'anonymous_id'; + +export interface AnonymousIdEnv { + readonly XDG_CONFIG_HOME?: string; + readonly HOME?: string; +} + +const safeRead = fromThrowable((path: string) => readFileSync(path, 'utf8').trim()); +const safeWrite = fromThrowable((dir: string, path: string, id: string) => { + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${id}\n`, { encoding: 'utf8', mode: 0o600 }); +}); + +export function getOrCreateAnonymousId(env: AnonymousIdEnv): string { + const dir = configDir(env); + const path = join(dir, FILE_NAME); + + const existing = safeRead(path).unwrapOr(''); + if (existing.length > 0) return existing; + + const id = crypto.randomUUID(); + // A read-only filesystem shouldn't break telemetry — discard the write + // Result and return the generated id either way. + safeWrite(dir, path, id); + return id; +} + +function configDir(env: AnonymousIdEnv): string { + if (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0) { + return join(env.XDG_CONFIG_HOME, APP_DIR_NAME); + } + const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); + return join(home, '.config', APP_DIR_NAME); +} diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..202ac9d --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from 'bun:test'; +import { strFromU8, unzipSync } from 'fflate'; +import { main } from './cli.ts'; +import { createFakeContext } from './testHelpers/index.ts'; + +test('prints usage and exits 0 when --help is passed', async () => { + const { ctx, io } = createFakeContext(); + const code = await main(ctx, ['--help']); + expect(code).toBe(0); + expect(io.stderr.text()).toContain('usage: patchwave-analysis'); +}); + +test('returns 1 and prints the usage when no target is provided', async () => { + const { ctx, io } = createFakeContext(); + const code = await main(ctx, []); + expect(code).toBe(1); + expect(io.stderr.text()).toContain('missing required argument'); +}); + +test('rejects an invalid --window value', async () => { + const { ctx, io } = createFakeContext(); + const code = await main(ctx, ['acme', '--window', 'foobar']); + expect(code).toBe(1); + expect(io.stderr.text()).toContain('invalid --window'); +}); + +test('writes a report when the GitHub calls succeed', async () => { + const { ctx, io, githubClient, fs, analytics } = createFakeContext(); + + githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([ + { + name: 'widgets', + owner: { login: 'acme' }, + private: true, + visibility: 'private', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + githubClient.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000 }); + githubClient.onRequest('GET /repos/{owner}/{repo}/contents/{path}', {}).resolves({ + content: Buffer.from('updates:\n - package-ecosystem: "npm"\n', 'utf8').toString('base64'), + encoding: 'base64', + }); + githubClient.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([]); + githubClient + .onRequest('GET /repos/{owner}/{repo}/branches/{branch}/protection', {}) + .fails({ kind: 'not-found', message: 'no protection' }); + githubClient.onRequest('GET /repos/{owner}/{repo}/rules/branches/{branch}', {}).resolves([]); + githubClient.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([]); + githubClient.onGraphql('DependabotPrs').resolves({ + search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }); + + const code = await main(ctx, ['acme', '--out', '/tmp/report']); + expect(code).toBe(0); + expect(io.stdout.text()).toContain('wrote /tmp/report.html'); + expect(io.stdout.text()).toContain('wrote /tmp/report.zip'); + + const written = fs.read('/tmp/report.html'); + expect(written).toBeDefined(); + expect(written).toContain('([\s\S]*?)<\/script>/.exec(written ?? ''); + expect(match).not.toBeNull(); + const embedded = JSON.parse(match?.[1] ?? '') as { meta: { org: string } }; + expect(embedded.meta.org).toBe('acme'); + + const zipBytes = fs.readBinary('/tmp/report.zip'); + expect(zipBytes).toBeInstanceOf(Uint8Array); + const entries = unzipSync(zipBytes as Uint8Array); + expect(Object.keys(entries).sort()).toEqual( + [ + 'README.txt', + 'data/aggregated.json', + 'data/branch-protection.json', + 'data/contributors.json', + 'data/cve.json', + 'data/dependabot-config.json', + 'data/dependabot-prs.json', + 'data/languages.json', + 'data/meta.json', + 'data/repos.json', + 'data/warnings.json', + 'patchwave-report.html', + ].sort(), + ); + const meta = JSON.parse(strFromU8(entries['data/meta.json'] as Uint8Array)) as Record; + expect(meta).toMatchObject({ + target: 'acme', + windowDays: 90, + counts: { reposTotal: 1, reposIncluded: 1, dependabotPrs: 0, warnings: 0 }, + }); + const repos = JSON.parse(strFromU8(entries['data/repos.json'] as Uint8Array)) as Array<{ name: string }>; + expect(repos).toHaveLength(1); + expect(repos[0]?.name).toBe('widgets'); + + expect(analytics.capturedEvents('run_started')[0]?.properties).toMatchObject({ + window_days: 90, + has_include: false, + has_exclude: false, + }); + const completed = analytics.capturedEvents('run_completed')[0]; + expect(completed?.properties).toMatchObject({ + window_days: 90, + repos_total: 1, + repos_included: 1, + dependabot_prs: 0, + warnings: 0, + }); + // org/repo names must never appear in telemetry payloads + expect(JSON.stringify(analytics.captureCalls)).not.toContain('acme'); + expect(JSON.stringify(analytics.captureCalls)).not.toContain('widgets'); +}); + +test('captures run_failed when listOrgRepos fails', async () => { + const { ctx, githubClient, analytics } = createFakeContext(); + githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); + + await main(ctx, ['acme']); + + const failed = analytics.capturedEvents('run_failed')[0]; + expect(failed?.properties).toMatchObject({ error_kind: 'forbidden' }); +}); + +test('exits 1 when listOrgRepos fails non-recoverably', async () => { + const { ctx, io, githubClient } = createFakeContext(); + githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const code = await main(ctx, ['acme']); + expect(code).toBe(1); + expect(io.stderr.text()).toContain('403'); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..828733b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,376 @@ +import { parseArgs } from 'node:util'; +import { Result, ResultAsync } from 'neverthrow'; +import pkg from '../package.json' with { type: 'json' }; +import { getBranchProtection } from './collectors/branchProtection.ts'; +import { listActiveCommitters } from './collectors/contributors.ts'; +import { getCveAlerts } from './collectors/cve.ts'; +import { getDependabotConfig } from './collectors/dependabotConfig.ts'; +import { listDependabotPrs } from './collectors/dependabotPrs.ts'; +import { getRepoLanguages, listOrgRepos } from './collectors/repos.ts'; +import { mapWithConcurrency } from './concurrency.ts'; +import type { Context } from './context.ts'; +import { getErrorMessage } from './errors.ts'; +import { formatFsError } from './FileSystem.ts'; +import { type GithubError, formatGithubError } from './github/errors.ts'; +import { type ReportBundle, aggregate } from './report/aggregate.ts'; +import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './report/bundle.ts'; +import { type RenderError, renderHtml } from './report/html.ts'; +import { type Instant, Temporal } from './time.ts'; +import type { + BranchProtectionSlice, + CollectedData, + CollectorWarning, + ContributorSlice, + CveSlice, + DependabotConfigSlice, + DependabotPr, + RepoLanguages, + RepoMeta, +} from './types.ts'; + +export interface CliOptions { + target: string; + windowDays: number; + outBase: string; + include: string[] | null; + exclude: string[]; +} + +export interface OutputPaths { + readonly html: string; + readonly zip: string; +} + +export function resolveOutputPaths(outBase: string): OutputPaths { + return { html: `${outBase}.html`, zip: `${outBase}.zip` }; +} + +export async function main(ctx: Context, argv: readonly string[]): Promise { + const parsed = parseCli(argv); + if (parsed.kind === 'err') { + if (parsed.message.length > 0) ctx.logger.error(parsed.message); + // usage text is a multi-line reference document, not a log line — bypass pino. + ctx.io.writeStderr(`${usage()}\n`); + return parsed.message.length > 0 ? 1 : 0; + } + const opts = parsed.value; + + const startedAt = ctx.clock.now(); + ctx.analytics.capture('run_started', { + window_days: opts.windowDays, + has_include: opts.include !== null, + has_exclude: opts.exclude.length > 0, + }); + + const renderResult = await renderReport(ctx, opts); + if (renderResult.isErr()) { + const error = renderResult.error; + ctx.logger.error(error.kind === 'missing-placeholder' ? error.message : formatGithubError(error)); + ctx.analytics.capture('run_failed', { + error_kind: error.kind, + duration_ms: elapsedMs(startedAt, ctx.clock.now()), + }); + return 1; + } + + const { report, collected, aggregated, stats } = renderResult.value; + const paths = resolveOutputPaths(opts.outBase); + + const htmlResult = await ctx.fs.writeTextFile(paths.html, report); + if (htmlResult.isErr()) { + ctx.logger.error(formatFsError(htmlResult.error)); + ctx.analytics.capture('run_failed', { + error_kind: 'write-failed', + duration_ms: elapsedMs(startedAt, ctx.clock.now()), + }); + return 1; + } + + const meta = buildBundleMeta(opts, stats, aggregated, collected); + const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: report }); + const zipResult = await ctx.fs.writeBinaryFile(paths.zip, zipBundleFiles(files)); + if (zipResult.isErr()) { + ctx.logger.error(formatFsError(zipResult.error)); + ctx.analytics.capture('run_failed', { + error_kind: 'write-failed', + duration_ms: elapsedMs(startedAt, ctx.clock.now()), + }); + return 1; + } + + ctx.io.writeStdout(`wrote ${paths.html}\n`); + ctx.io.writeStdout(`wrote ${paths.zip}\n`); + ctx.analytics.capture('run_completed', { + window_days: opts.windowDays, + repos_total: stats.reposTotal, + repos_included: stats.reposIncluded, + dependabot_prs: stats.dependabotPrs, + warnings: stats.warnings, + duration_ms: elapsedMs(startedAt, ctx.clock.now()), + }); + return 0; +} + +function buildBundleMeta( + opts: CliOptions, + stats: ReportStats, + aggregated: ReportBundle, + collected: CollectedData, +): BundleMeta { + return { + cliVersion: pkg.version, + generatedAt: aggregated.meta.generatedAt.toString(), + target: opts.target, + windowDays: opts.windowDays, + windowStart: collected.ctx.windowStart.toString(), + options: { include: opts.include, exclude: opts.exclude }, + counts: { + reposTotal: stats.reposTotal, + reposIncluded: stats.reposIncluded, + dependabotPrs: stats.dependabotPrs, + warnings: stats.warnings, + }, + }; +} + +interface ReportStats { + readonly reposTotal: number; + readonly reposIncluded: number; + readonly dependabotPrs: number; + readonly warnings: number; +} + +interface RenderedReport { + readonly report: string; + readonly collected: CollectedData; + readonly aggregated: ReportBundle; + readonly stats: ReportStats; +} + +function elapsedMs(start: Instant, end: Instant): number { + return Number(end.epochMilliseconds - start.epochMilliseconds); +} + +function renderReport(ctx: Context, opts: CliOptions): ResultAsync { + ctx.logger.info( + { target: opts.target, windowDays: opts.windowDays }, + `scanning ${opts.target} (${opts.windowDays}-day window)`, + ); + return listOrgRepos(ctx.githubClient, opts.target).andThen((repos) => { + const filtered = filterRepos(repos, opts); + ctx.logger.info( + { total: repos.length, included: filtered.length }, + `found ${repos.length} repos; ${filtered.length} included after filters`, + ); + const now = ctx.clock.now(); + const windowStart = now.subtract(Temporal.Duration.from({ hours: opts.windowDays * 24 })); + + return ResultAsync.fromSafePromise( + collectAll(ctx, filtered, opts.target, opts.windowDays, windowStart, now), + ).andThen((data) => { + ctx.logger.info( + { dependabotPrs: data.dependabotPrs.length, warnings: data.errors.length }, + `crawled ${data.dependabotPrs.length} Dependabot PRs; rendering report`, + ); + if (data.errors.length > 0) { + ctx.logger.warn( + { count: data.errors.length }, + `${data.errors.length} per-repo warnings were suppressed during crawl`, + ); + } + const bundle = aggregate(data); + return renderHtml(bundle).map( + (report): RenderedReport => ({ + report, + collected: data, + aggregated: bundle, + stats: { + reposTotal: repos.length, + reposIncluded: filtered.length, + dependabotPrs: data.dependabotPrs.length, + warnings: data.errors.length, + }, + }), + ); + }); + }); +} + +async function collectAll( + ctx: Context, + repos: RepoMeta[], + target: string, + windowDays: number, + windowStart: Instant, + now: Instant, +): Promise { + const client = ctx.githubClient; + const warnings: CollectorWarning[] = []; + const windowStartIso = windowStart.toString(); + + const [languages, dependabotConfig, cve, branchProtection, contributors, dependabotPrs] = await Promise.all([ + crawlPerRepo(repos, (r) => getRepoLanguages(client, { owner: r.owner, name: r.name }), warnings, 'languages').then( + (rows): RepoLanguages[] => rows.map((r) => ({ owner: r.ref.owner, name: r.ref.name, bytes: r.bytes })), + ), + crawlPerRepo( + repos, + (r) => getDependabotConfig(client, { owner: r.owner, name: r.name }), + warnings, + 'dependabotConfig', + ), + crawlPerRepo(repos, (r) => getCveAlerts(client, { owner: r.owner, name: r.name }), warnings, 'cve'), + crawlPerRepo( + repos, + (r) => getBranchProtection(client, { owner: r.owner, name: r.name }, r.defaultBranch), + warnings, + 'branchProtection', + ), + crawlPerRepo( + repos, + (r) => listActiveCommitters(client, { owner: r.owner, name: r.name }, windowStartIso), + warnings, + 'contributors', + ), + runResultAsync(listDependabotPrs(client, target, windowStartIso), [], warnings, 'dependabotPrs'), + ]); + + return { + ctx: { org: target, windowDays, windowStart, now }, + repos, + languages, + dependabotConfig, + dependabotPrs, + cve, + branchProtection, + contributors, + errors: warnings, + }; +} + +async function crawlPerRepo( + repos: readonly RepoMeta[], + fn: (repo: RepoMeta) => ResultAsync, + warnings: CollectorWarning[], + collector: string, +): Promise { + const results = await mapWithConcurrency(repos, 8, async (repo) => { + const result = await fn(repo); + return { repo, result }; + }); + const ok: T[] = []; + for (const { repo, result } of results) { + if (result.isOk()) { + ok.push(result.value); + } else { + warnings.push({ + collector, + repo: { owner: repo.owner, name: repo.name }, + message: formatGithubError(result.error), + }); + } + } + return ok; +} + +async function runResultAsync( + ra: ResultAsync, + fallback: T, + warnings: CollectorWarning[], + collector: string, +): Promise { + const result = await ra; + if (result.isOk()) return result.value; + warnings.push({ collector, message: formatGithubError(result.error) }); + return fallback; +} + +export type ParseCliResult = { kind: 'ok'; value: CliOptions } | { kind: 'err'; message: string }; + +const safeParseArgs = Result.fromThrowable( + (argv: readonly string[]) => + parseArgs({ + args: [...argv], + allowPositionals: true, + options: { + window: { type: 'string', default: '90d' }, + out: { type: 'string', default: './patchwave-report' }, + include: { type: 'string' }, + exclude: { type: 'string' }, + help: { type: 'boolean', default: false }, + }, + }), + getErrorMessage, +); + +export function parseCli(argv: readonly string[]): ParseCliResult { + const parseResult = safeParseArgs(argv); + if (parseResult.isErr()) { + return { kind: 'err', message: `failed to parse arguments: ${parseResult.error}` }; + } + const parsed = parseResult.value; + if (parsed.values.help) return { kind: 'err', message: '' }; + const positional = parsed.positionals; + const target = positional[0]; + if (target === undefined) { + return { kind: 'err', message: 'missing required argument: ' }; + } + + const windowDays = parseWindow(parsed.values.window); + if (windowDays === null) { + return { kind: 'err', message: `invalid --window: expected formats like '90d' or '12w'` }; + } + + return { + kind: 'ok', + value: { + target, + windowDays, + outBase: normalizeOutBase(parsed.values.out), + include: parsed.values.include ? splitCsv(parsed.values.include) : null, + exclude: parsed.values.exclude ? splitCsv(parsed.values.exclude) : [], + }, + }; +} + +function normalizeOutBase(out: string): string { + return out.replace(/\.(html|md|zip)$/i, ''); +} + +function parseWindow(s: string): number | null { + const match = /^(\d+)([dw])$/.exec(s.trim()); + if (!match) return null; + const n = Number.parseInt(match[1] ?? '', 10); + if (!Number.isFinite(n) || n <= 0) return null; + return match[2] === 'w' ? n * 7 : n; +} + +function splitCsv(s: string): string[] { + return s + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + +function filterRepos(repos: RepoMeta[], opts: CliOptions): RepoMeta[] { + let out = repos.filter((r) => !r.archived); + const includeSet = opts.include === null ? null : new Set(opts.include); + const excludeSet = new Set(opts.exclude); + if (includeSet !== null) out = out.filter((r) => includeSet.has(r.name)); + if (excludeSet.size > 0) out = out.filter((r) => !excludeSet.has(r.name)); + return out; +} + +function usage(): string { + return [ + '', + 'usage: patchwave-analysis [options]', + '', + 'options:', + ' --window rolling time window (default 90d)', + ' --out output basename; writes .html and .zip', + ' (default ./patchwave-report)', + ' --include comma-separated repo names to include', + ' --exclude comma-separated repo names to exclude', + ' --help show this help', + ].join('\n'); +} diff --git a/src/collectors/branchProtection.test.ts b/src/collectors/branchProtection.test.ts new file mode 100644 index 0000000..7485219 --- /dev/null +++ b/src/collectors/branchProtection.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getBranchProtection } from './branchProtection.ts'; + +const CLASSIC = 'GET /repos/{owner}/{repo}/branches/{branch}/protection'; +const RULES = 'GET /repos/{owner}/{repo}/rules/branches/{branch}'; + +test('builds a classic-only slice when rulesets return an empty list', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).resolves({ + required_pull_request_reviews: { required_approving_review_count: 2 }, + required_status_checks: { contexts: ['test'] }, + }); + client.onRequest(RULES, { branch: 'main' }).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['classic'], + requiredApprovingReviewCount: 2, + requiresStatusChecks: true, + }); + } +}); + +test('builds a ruleset-only slice when classic 404s but rulesets are active', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).fails({ kind: 'not-found', message: 'no classic' }); + client + .onRequest(RULES, { branch: 'main' }) + .resolves([ + { type: 'pull_request', parameters: { required_approving_review_count: 1 } }, + { type: 'required_status_checks', parameters: {} }, + { type: 'deletion' }, + ]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['ruleset'], + requiredApprovingReviewCount: 1, + requiresStatusChecks: true, + }); + } +}); + +test('merges classic + ruleset, taking the strictest review count and OR-ing status checks', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).resolves({ + required_pull_request_reviews: { required_approving_review_count: 1 }, + required_status_checks: null, + }); + client.onRequest(RULES, { branch: 'main' }).resolves([ + { type: 'pull_request', parameters: { required_approving_review_count: 2 } }, + { type: 'required_status_checks', parameters: {} }, + ]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['classic', 'ruleset'], + requiredApprovingReviewCount: 2, + requiresStatusChecks: true, + }); + } +}); + +test('returns hasProtection: false when both classic and rulesets are absent', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: false, + sources: [], + requiredApprovingReviewCount: null, + requiresStatusChecks: false, + }); + } +}); + +test('treats a 404 on the rules-for-branch endpoint as no rulesets', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).fails({ kind: 'not-found', message: 'no rules' }); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toMatchObject({ hasProtection: false, sources: [] }); +}); + +test('propagates non-404 classic errors so the partial-failure boundary can log it', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'http', status: 500, message: 'boom' }); + client.onRequest(RULES, {}).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); +}); + +test('propagates non-404 ruleset errors so the partial-failure boundary can log it', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).fails({ kind: 'http', status: 500, message: 'boom' }); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); +}); diff --git a/src/collectors/branchProtection.ts b/src/collectors/branchProtection.ts new file mode 100644 index 0000000..e960d55 --- /dev/null +++ b/src/collectors/branchProtection.ts @@ -0,0 +1,108 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { BranchProtectionSlice, BranchProtectionSource, RepoRef } from '../types.ts'; + +interface ClassicProtectionResponse { + required_pull_request_reviews?: { + required_approving_review_count?: number; + }; + required_status_checks?: { + contexts?: string[]; + } | null; +} + +interface RulesetRule { + type: string; + parameters?: { + required_approving_review_count?: number; + }; +} + +interface PartialProtection { + source: BranchProtectionSource; + requiredApprovingReviewCount: number | null; + requiresStatusChecks: boolean; +} + +export function getBranchProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + return ResultAsync.combine([ + getClassicProtection(client, ref, branch), + getRulesetProtection(client, ref, branch), + ]).map(([classic, ruleset]) => merge(ref, [classic, ruleset])); +} + +function getClassicProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + return client + .request('GET /repos/{owner}/{repo}/branches/{branch}/protection', { + owner: ref.owner, + repo: ref.name, + branch, + }) + .map( + (data): PartialProtection => ({ + source: 'classic', + requiredApprovingReviewCount: data.required_pull_request_reviews?.required_approving_review_count ?? null, + requiresStatusChecks: (data.required_status_checks?.contexts?.length ?? 0) > 0, + }), + ) + .orElse((err) => { + // 404 is the documented "no classic branch protection configured". + if (err.kind === 'not-found') return okAsync(null); + return errAsync(err); + }); +} + +function getRulesetProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + // The /rules/branches/{branch} endpoint returns the effective rules applied + // to a branch from any active ruleset (repo-level or inherited). It does NOT + // include classic branch protection — that's still a separate endpoint. + return client + .request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { + owner: ref.owner, + repo: ref.name, + branch, + }) + .map((rules): PartialProtection | null => { + if (rules.length === 0) return null; + const prRule = rules.find((r) => r.type === 'pull_request'); + const statusRule = rules.find((r) => r.type === 'required_status_checks'); + const reviewCount = prRule?.parameters?.required_approving_review_count; + return { + source: 'ruleset', + requiredApprovingReviewCount: typeof reviewCount === 'number' ? reviewCount : null, + requiresStatusChecks: statusRule !== undefined, + }; + }) + .orElse((err) => { + if (err.kind === 'not-found') return okAsync(null); + return errAsync(err); + }); +} + +function merge(ref: RepoRef, parts: ReadonlyArray): BranchProtectionSlice { + const active = parts.filter((p): p is PartialProtection => p !== null); + const reviewCounts = active + .map((p) => p.requiredApprovingReviewCount) + .filter((n): n is number => typeof n === 'number'); + return { + ...ref, + hasProtection: active.length > 0, + sources: active.map((p) => p.source), + // When multiple sources require reviews, the strictest one wins. + requiredApprovingReviewCount: reviewCounts.length > 0 ? Math.max(...reviewCounts) : null, + requiresStatusChecks: active.some((p) => p.requiresStatusChecks), + }; +} diff --git a/src/collectors/contributors.test.ts b/src/collectors/contributors.test.ts new file mode 100644 index 0000000..3969540 --- /dev/null +++ b/src/collectors/contributors.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { listActiveCommitters } from './contributors.ts'; + +test('returns unique human committers, sorted, skipping bots', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([ + { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, + { author: { login: 'bob', type: 'User' }, commit: { author: { name: 'b', date: '' } } }, + { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, + { author: { login: 'dependabot[bot]', type: 'Bot' }, commit: { author: { name: 'd', date: '' } } }, + { author: { login: 'renovate[bot]', type: 'User' }, commit: { author: { name: 'r', date: '' } } }, + { author: null, commit: { author: null } }, + ]); + + const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + owner: 'acme', + name: 'widgets', + activeHumanLogins: ['alice', 'bob'], + }); + } +}); + +test('propagates errors instead of swallowing them', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/collectors/contributors.ts b/src/collectors/contributors.ts new file mode 100644 index 0000000..1902b4e --- /dev/null +++ b/src/collectors/contributors.ts @@ -0,0 +1,34 @@ +import type { ResultAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { ContributorSlice, RepoRef } from '../types.ts'; + +interface ListCommitsItem { + author: { login: string; type?: string } | null; + commit: { author: { name: string; date: string } | null }; +} + +export function listActiveCommitters( + client: GithubClient, + ref: RepoRef, + windowStartIso: string, +): ResultAsync { + return client + .paginate('GET /repos/{owner}/{repo}/commits', { + owner: ref.owner, + repo: ref.name, + since: windowStartIso, + per_page: 100, + }) + .map((commits) => { + const logins = new Set(); + for (const c of commits) { + const author = c.author; + if (!author) continue; + if (author.type === 'Bot') continue; + if (author.login.endsWith('[bot]')) continue; + logins.add(author.login); + } + return { ...ref, activeHumanLogins: [...logins].sort() }; + }); +} diff --git a/src/collectors/cve.test.ts b/src/collectors/cve.test.ts new file mode 100644 index 0000000..dc26fba --- /dev/null +++ b/src/collectors/cve.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getCveAlerts } from './cve.ts'; + +test('maps raw alerts to CveAlert with normalized severity', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([ + { + number: 1, + state: 'open', + created_at: '2026-03-01T00:00:00Z', + security_advisory: { summary: 'Critical RCE' }, + security_vulnerability: { + severity: 'Critical', + package: { name: 'left-pad', ecosystem: 'npm' }, + }, + }, + { + number: 2, + state: 'open', + created_at: '2026-04-01T00:00:00Z', + security_advisory: { summary: 'Moderate issue' }, + security_vulnerability: { + severity: 'moderate', + package: { name: 'lodash', ecosystem: 'npm' }, + }, + }, + ]); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const slice = result.value; + expect(slice.status).toBe('ok'); + if (slice.status === 'ok') { + expect(slice.alerts).toHaveLength(2); + expect(slice.alerts[0]).toMatchObject({ severity: 'critical', packageName: 'left-pad' }); + expect(slice.alerts[1]).toMatchObject({ severity: 'medium', packageName: 'lodash' }); + } + } +}); + +test('converts a scope-missing error into the corresponding slice', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'scope-missing', required: 'security_events', message: 'missing scope' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ + owner: 'acme', + name: 'widgets', + status: 'scope-missing', + requiredScope: 'security_events', + }); + } +}); + +test("converts a 404 into 'not-enabled'", async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'not-found', message: 'not found' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toEqual({ owner: 'acme', name: 'widgets', status: 'not-enabled' }); +}); + +test("converts a 403 'Dependabot alerts are disabled' into 'not-enabled'", async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).fails({ + kind: 'forbidden', + message: 'Dependabot alerts are disabled for this repository.', + }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toEqual({ owner: 'acme', name: 'widgets', status: 'not-enabled' }); +}); + +test('propagates an unrelated 403 instead of swallowing it as not-enabled', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'forbidden', message: 'Must have admin rights to Repository.' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); + +test('propagates other errors instead of pretending alerts are disabled', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'http', status: 503, message: 'service unavailable' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 503 }); +}); diff --git a/src/collectors/cve.ts b/src/collectors/cve.ts new file mode 100644 index 0000000..273553a --- /dev/null +++ b/src/collectors/cve.ts @@ -0,0 +1,69 @@ +import { type ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { CveAlert, CveSeverity, CveSlice, RepoRef } from '../types.ts'; + +interface RawCveAlert { + number: number; + state: string; + created_at: string; + security_advisory: { summary: string }; + security_vulnerability: { + severity: string; + package: { name: string; ecosystem: string }; + }; +} + +export function getCveAlerts(client: GithubClient, ref: RepoRef): ResultAsync { + return client + .paginate('GET /repos/{owner}/{repo}/dependabot/alerts', { + owner: ref.owner, + repo: ref.name, + state: 'open', + per_page: 100, + }) + .map((raw): CveSlice => { + const alerts: CveAlert[] = raw.map((a) => ({ + owner: ref.owner, + name: ref.name, + number: a.number, + severity: normalizeSeverity(a.security_vulnerability.severity), + createdAt: a.created_at, + packageName: a.security_vulnerability.package.name, + ecosystem: a.security_vulnerability.package.ecosystem, + summary: a.security_advisory.summary, + })); + return { owner: ref.owner, name: ref.name, status: 'ok', alerts }; + }) + .orElse((err) => { + // Token missing the security_events scope: surface to the caller so the + // report can prompt the user to refresh auth, but don't fail the run. + if (err.kind === 'scope-missing') { + return okAsync({ + owner: ref.owner, + name: ref.name, + status: 'scope-missing', + requiredScope: err.required, + }); + } + // 404 is GitHub's signal that Dependabot alerts aren't enabled on this + // repo (or it doesn't exist for this token's scope) — semantic answer. + if (err.kind === 'not-found') { + return okAsync({ owner: ref.owner, name: ref.name, status: 'not-enabled' }); + } + // GitHub also returns 403 with body "Dependabot alerts are disabled for + // this repository." when the feature is off — treat the same as 404. + if (err.kind === 'forbidden' && /alerts are disabled/i.test(err.message)) { + return okAsync({ owner: ref.owner, name: ref.name, status: 'not-enabled' }); + } + return errAsync(err); + }); +} + +function normalizeSeverity(raw: string): CveSeverity { + const v = raw.toLowerCase(); + if (v === 'critical') return 'critical'; + if (v === 'high') return 'high'; + if (v === 'medium' || v === 'moderate') return 'medium'; + return 'low'; +} diff --git a/src/collectors/dependabotConfig.test.ts b/src/collectors/dependabotConfig.test.ts new file mode 100644 index 0000000..31af4f4 --- /dev/null +++ b/src/collectors/dependabotConfig.test.ts @@ -0,0 +1,162 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getDependabotConfig } from './dependabotConfig.ts'; + +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + +test('parses ecosystems from a dependabot.yml', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(` +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + `), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: ['github-actions', 'npm'], + updates: [ + { ecosystem: 'npm', interval: 'weekly', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, + { ecosystem: 'github-actions', interval: 'daily', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, + ], + }); + } +}); + +test('captures open-pull-requests-limit, groups, and ignore counts per entry', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(` +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 20 + groups: + eslint: + patterns: + - "eslint*" + react: + patterns: + - "react*" + - "react-dom" + ignore: + - dependency-name: "lodash" + - dependency-name: "express" + versions: ["4.x"] + - dependency-name: "react" +`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.updates).toEqual([ + { + ecosystem: 'npm', + interval: 'monthly', + openPullRequestsLimit: 20, + groupCount: 2, + ignoreCount: 3, + }, + ]); + } +}); + +test('defaults openPullRequestsLimit to 5 when not specified', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(`updates:\n - package-ecosystem: bundler\n schedule:\n interval: weekly\n`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value.updates[0]?.openPullRequestsLimit).toBe(5); +}); + +test('falls back to dependabot.yaml when .yml is absent', async () => { + const client = new FakeGithubClient(); + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) + .fails({ kind: 'not-found', message: 'no .yml' }); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yaml' }).resolves({ + content: base64(`updates:\n - package-ecosystem: bundler\n`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: ['bundler'], + }); + } +}); + +test('returns hasConfig: false when both paths 404 but the call still succeeds', async () => { + const client = new FakeGithubClient(); + for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml']) { + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path }) + .fails({ kind: 'not-found', message: 'no config' }); + } + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: false, + ecosystems: [], + updates: [], + }); + } +}); + +test('returns an empty updates list when the YAML is malformed', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64('this: : is\n: not valid: yaml: [\n'), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: [], + updates: [], + }); + } +}); + +test('propagates non-404 errors when fetching the config', async () => { + const client = new FakeGithubClient(); + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) + .fails({ kind: 'forbidden', message: 'no access' }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/collectors/dependabotConfig.ts b/src/collectors/dependabotConfig.ts new file mode 100644 index 0000000..a85efcb --- /dev/null +++ b/src/collectors/dependabotConfig.ts @@ -0,0 +1,108 @@ +import { Result, ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { DependabotConfigSlice, DependabotInterval, DependabotUpdateEntry, RepoRef } from '../types.ts'; + +interface ContentResponse { + content?: string; + encoding?: string; +} + +const CONFIG_PATHS = ['.github/dependabot.yml', '.github/dependabot.yaml']; + +const DEFAULT_OPEN_PR_LIMIT = 5; + +const safeYamlParse = Result.fromThrowable( + (text: string) => Bun.YAML.parse(text), + () => null, +); + +export function getDependabotConfig( + client: GithubClient, + ref: RepoRef, +): ResultAsync { + return fetchFirstAvailable(client, ref, CONFIG_PATHS).map((configBody): DependabotConfigSlice => { + const hasConfig = configBody !== null; + const updates = configBody === null ? [] : parseUpdates(configBody); + const ecosystems = [...new Set(updates.map((u) => u.ecosystem))].sort(); + return { ...ref, hasConfig, ecosystems, updates }; + }); +} + +function fetchFirstAvailable( + client: GithubClient, + ref: RepoRef, + paths: readonly string[], +): ResultAsync { + const [head, ...rest] = paths; + if (head === undefined) return okAsync(null); + return client + .request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: ref.owner, + repo: ref.name, + path: head, + }) + .map((data) => decodeContent(data)) + .orElse((err) => { + if (err.kind === 'not-found') return fetchFirstAvailable(client, ref, rest); + return errAsync(err); + }); +} + +function decodeContent(data: ContentResponse): string | null { + if (!data.content) return null; + if (data.encoding && data.encoding !== 'base64') return null; + return Buffer.from(data.content, 'base64').toString('utf8'); +} + +function parseUpdates(yamlText: string): DependabotUpdateEntry[] { + const parsed = safeYamlParse(yamlText).unwrapOr(null); + if (!isRecord(parsed)) return []; + const rawUpdates = parsed.updates; + if (!Array.isArray(rawUpdates)) return []; + const entries: DependabotUpdateEntry[] = []; + for (const raw of rawUpdates) { + const entry = normalizeUpdate(raw); + if (entry !== null) entries.push(entry); + } + return entries; +} + +function normalizeUpdate(raw: unknown): DependabotUpdateEntry | null { + if (!isRecord(raw)) return null; + const ecosystem = typeof raw['package-ecosystem'] === 'string' ? raw['package-ecosystem'] : null; + if (ecosystem === null) return null; + return { + ecosystem, + interval: extractInterval(raw.schedule), + openPullRequestsLimit: extractOpenPrLimit(raw['open-pull-requests-limit']), + groupCount: extractGroupCount(raw.groups), + ignoreCount: extractListCount(raw.ignore), + }; +} + +function extractInterval(schedule: unknown): DependabotInterval | null { + if (!isRecord(schedule)) return null; + const value = schedule.interval; + if (value === 'daily' || value === 'weekly' || value === 'monthly') return value; + return null; +} + +function extractOpenPrLimit(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value); + return DEFAULT_OPEN_PR_LIMIT; +} + +function extractGroupCount(value: unknown): number { + if (!isRecord(value)) return 0; + return Object.keys(value).length; +} + +function extractListCount(value: unknown): number { + if (!Array.isArray(value)) return 0; + return value.length; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/collectors/dependabotPrs.test.ts b/src/collectors/dependabotPrs.test.ts new file mode 100644 index 0000000..c7c1d8b --- /dev/null +++ b/src/collectors/dependabotPrs.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { listDependabotPrs } from './dependabotPrs.ts'; +import { rawPullRequest } from './testFactories.ts'; + +test('maps a single page of search results to DependabotPr', async () => { + const client = new FakeGithubClient(); + client.onGraphql('DependabotPrs').resolves({ + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + rawPullRequest.build({ + state: 'MERGED', + mergedAt: '2026-04-05T00:00:00Z', + mergedBy: { login: 'alice' }, + reviews: { nodes: [{ author: { login: 'bob' } }, { author: { login: 'alice' } }] }, + comments: { nodes: [{ author: { login: 'alice' } }] }, + }), + ], + }, + }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + const prs = result.unwrapOr([]); + expect(prs).toHaveLength(1); + expect(prs[0]).toMatchObject({ + owner: 'acme', + name: 'widgets', + state: 'closed', + merged: true, + mergedBy: 'alice', + reviewers: ['alice', 'bob'], + commenters: ['alice'], + }); +}); + +test('pages through results when hasNextPage is true', async () => { + const client = new FakeGithubClient(); + // First page returns cursor; second returns no more. + client + .onGraphql((_q, vars) => vars.cursor === null) + .resolves({ + search: { + pageInfo: { hasNextPage: true, endCursor: 'CURSOR_1' }, + nodes: [rawPullRequest.build({ number: 1 })], + }, + }); + client + .onGraphql((_q, vars) => vars.cursor === 'CURSOR_1') + .resolves({ + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [rawPullRequest.build({ number: 2 })], + }, + }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + expect(result.unwrapOr([]).map((p) => p.number)).toEqual([1, 2]); + expect(client.callsTo('graphql')).toHaveLength(2); +}); + +test('propagates errors from the GraphQL call', async () => { + const client = new FakeGithubClient(); + client.onGraphql('DependabotPrs').fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isErr()).toBe(true); +}); diff --git a/src/collectors/dependabotPrs.ts b/src/collectors/dependabotPrs.ts new file mode 100644 index 0000000..a5e48a6 --- /dev/null +++ b/src/collectors/dependabotPrs.ts @@ -0,0 +1,215 @@ +import { ResultAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { CheckSummary, DependabotPr, PrState } from '../types.ts'; + +interface GraphqlSearchResponse { + search: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array; + }; +} + +export interface RawPullRequest { + number: number; + title: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + createdAt: string; + closedAt: string | null; + mergedAt: string | null; + url: string; + baseRefName: string; + headRefName: string; + mergedBy: { login: string } | null; + autoMergeRequest: { enabledAt: string | null } | null; + repository: { owner: { login: string }; name: string }; + reviews: { nodes: Array<{ author: { login: string } | null } | null> }; + comments: { nodes: Array<{ author: { login: string } | null } | null> }; + commits: { + nodes: Array<{ + commit: { + statusCheckRollup: { + contexts: { + nodes: Array< + | { __typename: 'CheckRun'; name: string; conclusion: string | null } + | { __typename: 'StatusContext'; context: string; state: string } + | null + >; + }; + } | null; + }; + } | null>; + }; +} + +const SEARCH_QUERY = /* GraphQL */ ` + query DependabotPrs($searchQuery: String!, $cursor: String) { + search(query: $searchQuery, type: ISSUE, first: 50, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on PullRequest { + number + title + state + createdAt + closedAt + mergedAt + url + baseRefName + headRefName + mergedBy { + login + } + autoMergeRequest { + enabledAt + } + repository { + owner { + login + } + name + } + reviews(first: 50) { + nodes { + author { + login + } + } + } + comments(first: 50) { + nodes { + author { + login + } + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + conclusion + } + ... on StatusContext { + context + state + } + } + } + } + } + } + } + } + } + } + } +`; + +export function listDependabotPrs( + client: GithubClient, + org: string, + windowStartIso: string, +): ResultAsync { + const searchQuery = [`is:pr`, `author:app/dependabot`, `org:${org}`, `updated:>=${windowStartIso.slice(0, 10)}`].join( + ' ', + ); + return pageThrough(client, searchQuery, null, []); +} + +function pageThrough( + client: GithubClient, + searchQuery: string, + cursor: string | null, + acc: DependabotPr[], +): ResultAsync { + return client.graphql(SEARCH_QUERY, { searchQuery, cursor }).andThen((res) => { + for (const node of res.search.nodes) { + if (node === null) continue; + acc.push(toDependabotPr(node)); + } + if (res.search.pageInfo.hasNextPage && res.search.pageInfo.endCursor) { + return pageThrough(client, searchQuery, res.search.pageInfo.endCursor, acc); + } + return okAsync(acc); + }); +} + +function toDependabotPr(raw: RawPullRequest): DependabotPr { + const state: PrState = raw.state === 'OPEN' ? 'open' : 'closed'; + const merged = raw.state === 'MERGED'; + const reviewers = uniqueLogins(raw.reviews.nodes.map((n) => n?.author?.login)); + const commenters = uniqueLogins(raw.comments.nodes.map((n) => n?.author?.login)); + return { + owner: raw.repository.owner.login, + name: raw.repository.name, + number: raw.number, + title: raw.title, + state, + merged, + createdAt: raw.createdAt, + closedAt: raw.closedAt, + mergedAt: raw.mergedAt, + mergedBy: raw.mergedBy?.login ?? null, + headRef: raw.headRefName, + baseRef: raw.baseRefName, + htmlUrl: raw.url, + reviewers, + commenters, + autoMergeEnabled: raw.autoMergeRequest !== null, + checks: summarizeChecks(raw), + }; +} + +function summarizeChecks(raw: RawPullRequest): CheckSummary { + const summary: CheckSummary = { + total: 0, + success: 0, + failure: 0, + pending: 0, + failedCheckNames: [], + }; + const commitNode = raw.commits.nodes[0]; + const rollup = commitNode?.commit.statusCheckRollup; + if (!rollup) return summary; + for (const ctx of rollup.contexts.nodes) { + if (ctx === null) continue; + summary.total += 1; + if (ctx.__typename === 'CheckRun') { + const c = (ctx.conclusion ?? '').toUpperCase(); + if (c === 'SUCCESS' || c === 'NEUTRAL' || c === 'SKIPPED') { + summary.success += 1; + } else if (c === 'FAILURE' || c === 'TIMED_OUT' || c === 'CANCELLED' || c === 'ACTION_REQUIRED') { + summary.failure += 1; + summary.failedCheckNames.push(ctx.name); + } else { + summary.pending += 1; + } + } else { + const s = ctx.state.toUpperCase(); + if (s === 'SUCCESS') summary.success += 1; + else if (s === 'FAILURE' || s === 'ERROR') { + summary.failure += 1; + summary.failedCheckNames.push(ctx.context); + } else summary.pending += 1; + } + } + return summary; +} + +function uniqueLogins(values: Array): string[] { + const seen = new Set(); + for (const v of values) { + if (!v) continue; + if (v.endsWith('[bot]')) continue; + seen.add(v); + } + return [...seen].sort(); +} diff --git a/src/collectors/repos.test.ts b/src/collectors/repos.test.ts new file mode 100644 index 0000000..bf22a2f --- /dev/null +++ b/src/collectors/repos.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getRepoLanguages, listOrgRepos } from './repos.ts'; + +test('maps raw repo payloads into RepoMeta and infers visibility', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).resolves([ + { + name: 'widgets', + owner: { login: 'acme' }, + private: false, + visibility: 'public', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + security_and_analysis: { dependabot_security_updates: { status: 'enabled' } }, + }, + { + name: 'internal-tool', + owner: { login: 'acme' }, + private: true, + visibility: 'internal', + archived: true, + default_branch: 'main', + language: null, + pushed_at: null, + }, + ]); + + const result = await listOrgRepos(client, 'acme'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const repos = result.value; + expect(repos).toHaveLength(2); + expect(repos[0]).toMatchObject({ + owner: 'acme', + name: 'widgets', + visibility: 'public', + archived: false, + dependabotSecurityUpdates: true, + }); + expect(repos[1]).toMatchObject({ + visibility: 'internal', + archived: true, + dependabotSecurityUpdates: null, + }); + } +}); + +test('falls back to the user endpoint when the org endpoint 404s', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'not-found', message: 'no org' }); + client.onPaginate('GET /users/{username}/repos', {}).resolves([ + { + name: 'solo', + owner: { login: 'blimmer' }, + private: false, + visibility: 'public', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + + const result = await listOrgRepos(client, 'blimmer'); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value[0]).toMatchObject({ owner: 'blimmer', name: 'solo' }); +}); + +test('propagates non-404 errors from listOrgRepos', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listOrgRepos(client, 'acme'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); + +test('getRepoLanguages returns bytes keyed by language', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000, JavaScript: 200 }); + + const result = await getRepoLanguages(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ + ref: { owner: 'acme', name: 'widgets' }, + bytes: { TypeScript: 1000, JavaScript: 200 }, + }); + } +}); diff --git a/src/collectors/repos.ts b/src/collectors/repos.ts new file mode 100644 index 0000000..028dd01 --- /dev/null +++ b/src/collectors/repos.ts @@ -0,0 +1,61 @@ +import { ResultAsync, errAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { RepoMeta, RepoRef, Visibility } from '../types.ts'; + +interface RawRepo { + name: string; + owner: { login: string }; + private: boolean; + visibility?: string; + archived: boolean; + default_branch: string; + language: string | null; + pushed_at: string | null; + security_and_analysis?: { + dependabot_security_updates?: { status: 'enabled' | 'disabled' }; + } | null; +} + +export function listOrgRepos(client: GithubClient, org: string): ResultAsync { + return client + .paginate('GET /orgs/{org}/repos', { org, per_page: 100, type: 'all' }) + .orElse((err) => { + if (err.kind === 'not-found') { + return client.paginate('GET /users/{username}/repos', { + username: org, + per_page: 100, + type: 'owner', + }); + } + return errAsync(err); + }) + .map((repos) => repos.map(toRepoMeta)); +} + +export function getRepoLanguages( + client: GithubClient, + ref: RepoRef, +): ResultAsync<{ ref: RepoRef; bytes: Record }, GithubError> { + return client + .request>('GET /repos/{owner}/{repo}/languages', { + owner: ref.owner, + repo: ref.name, + }) + .map((bytes) => ({ ref, bytes })); +} + +function toRepoMeta(raw: RawRepo): RepoMeta { + const visibility: Visibility = raw.visibility === 'internal' ? 'internal' : raw.private ? 'private' : 'public'; + const securityUpdates = raw.security_and_analysis?.dependabot_security_updates?.status; + return { + owner: raw.owner.login, + name: raw.name, + visibility, + archived: raw.archived, + defaultBranch: raw.default_branch, + primaryLanguage: raw.language, + pushedAt: raw.pushed_at, + dependabotSecurityUpdates: securityUpdates === undefined ? null : securityUpdates === 'enabled', + }; +} diff --git a/src/collectors/testFactories.ts b/src/collectors/testFactories.ts new file mode 100644 index 0000000..f754acc --- /dev/null +++ b/src/collectors/testFactories.ts @@ -0,0 +1,20 @@ +import { Factory } from 'fishery'; +import type { RawPullRequest } from './dependabotPrs.ts'; + +export const rawPullRequest = Factory.define(() => ({ + number: 1, + title: 'Bump lodash from 4.17.20 to 4.17.21', + state: 'OPEN', + createdAt: '2026-04-01T00:00:00Z', + closedAt: null, + mergedAt: null, + url: 'https://github.com/acme/widgets/pull/1', + baseRefName: 'main', + headRefName: 'dependabot/npm_and_yarn/lodash-4.17.21', + mergedBy: null, + autoMergeRequest: null, + repository: { owner: { login: 'acme' }, name: 'widgets' }, + reviews: { nodes: [] }, + comments: { nodes: [] }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, +})); diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 0000000..792c1e3 --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,22 @@ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: U[] = new Array(items.length); + let cursor = 0; + + async function worker(): Promise { + while (true) { + const i = cursor++; + if (i >= items.length) return; + const item = items[i]; + if (item === undefined) continue; + results[i] = await fn(item, i); + } + } + + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); + await Promise.all(workers); + return results; +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..b17beae --- /dev/null +++ b/src/context.ts @@ -0,0 +1,47 @@ +import type { Analytics } from './Analytics.ts'; +import { NoopAnalytics } from './Analytics.ts'; +import type { Io } from './BaseIo.ts'; +import type { Clock } from './Clock.ts'; +import { ClockImpl } from './Clock.ts'; +import { type Environment, getEnvironment } from './environment.ts'; +import type { FileSystem } from './FileSystem.ts'; +import { FileSystemImpl } from './FileSystem.ts'; +import type { GithubClient } from './github/GithubClient.ts'; +import { GithubClientImpl } from './github/GithubClient.ts'; +import { IoImpl } from './IoImpl.ts'; +import { type Logger, createLogger } from './logger.ts'; + +export interface Context { + readonly io: Io; + readonly logger: Logger; + readonly env: Environment; + readonly clock: Clock; + readonly fs: FileSystem; + readonly githubClient: GithubClient; + readonly analytics: Analytics; +} + +export interface CreateContextOptions { + readonly token: string; + readonly io?: Io; + readonly logger?: Logger; + readonly env?: Environment; + readonly clock?: Clock; + readonly fs?: FileSystem; + readonly githubClient?: GithubClient; + readonly analytics?: Analytics; +} + +export function createContext(options: CreateContextOptions): Context { + const { + token, + io = new IoImpl(), + env = getEnvironment(), + clock = new ClockImpl(), + fs = new FileSystemImpl(), + analytics = new NoopAnalytics(), + } = options; + const logger = options.logger ?? createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); + const githubClient = options.githubClient ?? new GithubClientImpl({ token, logger }); + return { io, logger, env, clock, fs, githubClient, analytics }; +} diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 0000000..6f401a9 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const booleanEnv = z.stringbool({ truthy: ['1', 'true', 'yes'], falsy: ['', '0', 'false', 'no'] }).default(false); + +const EnvironmentSchema = z.object({ + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']).default('info'), + DO_NOT_TRACK: booleanEnv, + CONTEXTBRIDGE_TELEMETRY_DISABLED: booleanEnv, + CI: booleanEnv, + XDG_CONFIG_HOME: z.string().optional(), + HOME: z.string().optional(), +}); + +export type Environment = z.infer; + +export function getEnvironment(env: NodeJS.ProcessEnv = process.env): Environment { + return EnvironmentSchema.parse(env); +} + +export interface TelemetryOptOutEnv { + readonly DO_NOT_TRACK?: boolean; + readonly CONTEXTBRIDGE_TELEMETRY_DISABLED?: boolean; + readonly CI?: boolean; +} + +export function isTelemetryDisabled(env: TelemetryOptOutEnv): boolean { + return Boolean(env.DO_NOT_TRACK || env.CONTEXTBRIDGE_TELEMETRY_DISABLED || env.CI); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..4ef51dd --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,7 @@ +export function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/github/GithubClient.ts b/src/github/GithubClient.ts new file mode 100644 index 0000000..4bc75f0 --- /dev/null +++ b/src/github/GithubClient.ts @@ -0,0 +1,67 @@ +import { graphql as graphqlBase } from '@octokit/graphql'; +import { retry } from '@octokit/plugin-retry'; +import { throttling } from '@octokit/plugin-throttling'; +import { Octokit } from '@octokit/rest'; +import { ResultAsync } from 'neverthrow'; +import type { Logger } from '../logger.ts'; +import { type GithubError, toGithubError } from './errors.ts'; + +const PatchwaveOctokit = Octokit.plugin(retry, throttling); + +function noop(): void {} + +/** + * Narrow GitHub API surface the collectors depend on. Each method returns a + * ResultAsync so callers can chain without try/catch. Production wires this to + * Octokit + @octokit/graphql; tests use FakeGithubClient. + */ +export interface GithubClient { + paginate(route: string, params?: Record): ResultAsync; + request(route: string, params?: Record): ResultAsync; + graphql(query: string, variables?: Record): ResultAsync; +} + +export interface GithubClientImplOptions { + readonly token: string; + readonly logger: Logger; + readonly userAgent?: string; +} + +export class GithubClientImpl implements GithubClient { + private readonly rest: InstanceType; + private readonly graphqlClient: typeof graphqlBase; + + constructor(options: GithubClientImplOptions) { + const { token, logger, userAgent = 'patchwave-analysis' } = options; + this.rest = new PatchwaveOctokit({ + auth: token, + userAgent, + log: { + debug: noop, + info: noop, + warn: noop, + error: (msg: string) => logger.error({ source: 'octokit' }, msg), + }, + retry: { doNotRetry: [400, 401, 403, 404, 409, 422] }, + throttle: { + onRateLimit: (_retryAfter, _opts, _octokit, retryCount) => retryCount < 2, + onSecondaryRateLimit: () => true, + }, + }); + this.graphqlClient = graphqlBase.defaults({ + headers: { authorization: `token ${token}` }, + }); + } + + paginate(route: string, params: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.rest.paginate(route, params), toGithubError); + } + + request(route: string, params: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.rest.request(route, params), toGithubError).map((res) => res.data as T); + } + + graphql(query: string, variables: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.graphqlClient(query, variables), toGithubError); + } +} diff --git a/src/github/auth.ts b/src/github/auth.ts new file mode 100644 index 0000000..caae6ab --- /dev/null +++ b/src/github/auth.ts @@ -0,0 +1,33 @@ +import { $ } from 'bun'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import { getErrorMessage } from '../errors.ts'; + +export type AuthError = { kind: 'no-token'; message: string } | { kind: 'gh-failed'; message: string }; + +export function resolveToken(): ResultAsync { + const envToken = (Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN ?? '').trim(); + if (envToken.length > 0) return okAsync(envToken); + + return ResultAsync.fromPromise( + $`gh auth token`.quiet().text(), + (e): AuthError => ({ kind: 'gh-failed', message: getErrorMessage(e) }), + ).andThen((raw) => { + const token = raw.trim(); + if (token.length === 0) { + return errAsync({ + kind: 'no-token', + message: 'gh auth token returned an empty string', + }); + } + return okAsync(token); + }); +} + +export function formatAuthError(err: AuthError): string { + switch (err.kind) { + case 'no-token': + return `no GitHub token available.\n fix: set GITHUB_TOKEN, or run 'gh auth login' to use the gh CLI`; + case 'gh-failed': + return `failed to read token from gh CLI: ${err.message}\n fix: set GITHUB_TOKEN, or run 'gh auth login'`; + } +} diff --git a/src/github/errors.test.ts b/src/github/errors.test.ts new file mode 100644 index 0000000..834a846 --- /dev/null +++ b/src/github/errors.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'bun:test'; +import { toGithubError } from './errors.ts'; +import { requestError } from './testFactories.ts'; + +test('403 with a present scope that matches an accepted scope is forbidden, not scope-missing', () => { + // GitHub's x-accepted-oauth-scopes header is OR semantics — any one scope in + // the list grants access. If the token has any of them, this 403 is not a + // token problem (it's typically a per-repo feature disabled / permission). + const err = requestError.build({ + response: { + headers: { + 'x-accepted-oauth-scopes': 'admin:repo_hook, repo, security_events', + 'x-oauth-scopes': 'gist, read:org, repo', + }, + }, + message: 'Dependabot alerts are disabled for this repository.', + }); + expect(toGithubError(err)).toMatchObject({ kind: 'forbidden' }); +}); + +test('403 with no overlapping scopes is scope-missing, reporting the first accepted scope', () => { + const err = requestError.build({ + response: { + headers: { + 'x-accepted-oauth-scopes': 'security_events, repo', + 'x-oauth-scopes': 'read:org', + }, + }, + }); + expect(toGithubError(err)).toMatchObject({ kind: 'scope-missing', required: 'security_events' }); +}); + +test('403 with no x-accepted-oauth-scopes header is forbidden', () => { + const err = requestError.build({ response: { headers: {} } }); + expect(toGithubError(err)).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/github/errors.ts b/src/github/errors.ts new file mode 100644 index 0000000..e204507 --- /dev/null +++ b/src/github/errors.ts @@ -0,0 +1,80 @@ +import { getErrorMessage } from '../errors.ts'; + +export type GithubError = + | { kind: 'network'; message: string; cause: Error } + | { kind: 'not-found'; url?: string; message: string } + | { kind: 'scope-missing'; required: string; message: string } + | { kind: 'forbidden'; url?: string; message: string } + | { kind: 'http'; status: number; url?: string; message: string }; + +interface RequestErrorLike { + status?: number; + message?: string; + request?: { url?: string }; + response?: { headers?: Record }; +} + +export function toGithubError(err: unknown): GithubError { + const e = err as RequestErrorLike & Error; + const url = e?.request?.url; + const status = typeof e?.status === 'number' ? e.status : undefined; + const message = getErrorMessage(err); + + if (status === undefined) { + return { kind: 'network', message, cause: err instanceof Error ? err : new Error(message) }; + } + + if (status === 404) return { kind: 'not-found', url, message }; + + if (status === 403) { + const required = detectMissingScope(e); + if (required) { + return { + kind: 'scope-missing', + required, + message: `GitHub returned 403 for ${url ?? 'an API call'}; the access token is missing the '${required}' scope.`, + }; + } + return { kind: 'forbidden', url, message }; + } + + return { kind: 'http', status, url, message }; +} + +function detectMissingScope(err: RequestErrorLike): string | null { + const accepted = err.response?.headers?.['x-accepted-oauth-scopes']; + const present = err.response?.headers?.['x-oauth-scopes']; + if (!accepted) return null; + const acceptedScopes = accepted + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (acceptedScopes.length === 0) return null; + const presentScopes = new Set( + (present ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + // x-accepted-oauth-scopes is OR semantics: any one of the listed scopes + // grants access. If the token has any of them, this 403 isn't a scope + // problem — GitHub also returns 403 for things like per-repo features + // being disabled or insufficient repo-level permissions. + if (acceptedScopes.some((s) => presentScopes.has(s))) return null; + return acceptedScopes[0] ?? null; +} + +export function formatGithubError(err: GithubError): string { + switch (err.kind) { + case 'network': + return `network error talking to GitHub: ${err.message}`; + case 'not-found': + return `GitHub returned 404 for ${err.url ?? 'an API call'}`; + case 'scope-missing': + return `${err.message}\n fix: gh auth refresh -s ${err.required}`; + case 'forbidden': + return `GitHub returned 403 for ${err.url ?? 'an API call'}: ${err.message}`; + case 'http': + return `GitHub returned ${err.status} for ${err.url ?? 'an API call'}: ${err.message}`; + } +} diff --git a/src/github/testFactories.ts b/src/github/testFactories.ts new file mode 100644 index 0000000..bc18783 --- /dev/null +++ b/src/github/testFactories.ts @@ -0,0 +1,19 @@ +import { Factory } from 'fishery'; + +export interface RequestErrorLike { + status: number; + message: string; + request: { url: string }; + response: { headers: Record }; +} + +// Octokit surfaces failures as an Error carrying status/request/response. Fishery +// preserves the Error prototype because the generator returns a non-plain object, +// so overrides merge onto a real Error instance. +export const requestError = Factory.define(() => { + const err = new Error('forbidden') as Error & RequestErrorLike; + err.status = 403; + err.request = { url: 'https://api.github.com/test' }; + err.response = { headers: {} }; + return err; +}); diff --git a/src/heuristics/bumpType.test.ts b/src/heuristics/bumpType.test.ts new file mode 100644 index 0000000..343fd74 --- /dev/null +++ b/src/heuristics/bumpType.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test'; +import { classifyBumpType, isDevDependencyBump } from './bumpType.ts'; + +test('classifies a patch bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 4.17.21')).toBe('patch'); +}); + +test('classifies a minor bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 4.18.0')).toBe('minor'); +}); + +test('classifies a major bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 5.0.0')).toBe('major'); +}); + +test('handles conventional commit prefix', () => { + expect(classifyBumpType('build(deps): bump react from 18.2.0 to 18.3.0')).toBe('minor'); +}); + +test('handles dev-deps conventional commit prefix', () => { + expect(classifyBumpType('build(deps-dev): bump @types/node from 18.0.0 to 18.0.1')).toBe('patch'); +}); + +test('returns other for unparseable titles', () => { + expect(classifyBumpType('Update some dependency')).toBe('other'); +}); + +test('classifies grouped Dependabot updates as grouped', () => { + expect(classifyBumpType('Bump the production-dependencies group with 5 updates')).toBe('grouped'); + expect(classifyBumpType('Bump the github-actions group across 3 directories with 6 updates')).toBe('grouped'); + expect(classifyBumpType('build(deps): bump the prod-deps group with 4 updates')).toBe('grouped'); +}); + +test('detects dev-dependency bumps via conventional commit', () => { + expect(isDevDependencyBump('build(deps-dev): bump @types/node from 18.0.0 to 18.0.1')).toBe(true); + expect(isDevDependencyBump('build(deps): bump react from 18.0.0 to 18.0.1')).toBe(false); + expect(isDevDependencyBump('Bump react from 18.0.0 to 18.0.1')).toBe(false); +}); diff --git a/src/heuristics/bumpType.ts b/src/heuristics/bumpType.ts new file mode 100644 index 0000000..11e5bf3 --- /dev/null +++ b/src/heuristics/bumpType.ts @@ -0,0 +1,32 @@ +import { Result } from 'neverthrow'; +import semver from 'semver'; + +export type BumpType = 'patch' | 'minor' | 'major' | 'grouped' | 'other'; + +const BUMP_RE = /[Bb]ump\s+\S+\s+from\s+([^\s]+)\s+to\s+([^\s]+)/; +const GROUPED_RE = /\bbump the \S+ group\b/i; + +const safeDiff = Result.fromThrowable( + (from: string | semver.SemVer, to: string | semver.SemVer) => semver.diff(from, to), + () => 'diff-failed' as const, +); + +export function classifyBumpType(prTitle: string): BumpType { + if (GROUPED_RE.test(prTitle)) return 'grouped'; + const match = BUMP_RE.exec(prTitle); + if (!match) return 'other'; + const from = semver.coerce(match[1] ?? ''); + const to = semver.coerce(match[2] ?? ''); + if (!from || !to) return 'other'; + + const diff = safeDiff(from, to).unwrapOr(null); + if (diff === null) return 'other'; + if (diff === 'major' || diff === 'premajor') return 'major'; + if (diff === 'minor' || diff === 'preminor') return 'minor'; + if (diff === 'patch' || diff === 'prepatch' || diff === 'prerelease') return 'patch'; + return 'other'; +} + +export function isDevDependencyBump(prTitle: string): boolean { + return /\(deps-dev\)/.test(prTitle); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ecd15e6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun +import pkg from '../package.json' with { type: 'json' }; +import { type Analytics, AnalyticsImpl, NoopAnalytics } from './Analytics.ts'; +import { getOrCreateAnonymousId } from './anonymousId.ts'; +import { main } from './cli.ts'; +import { createContext } from './context.ts'; +import { getEnvironment, isTelemetryDisabled } from './environment.ts'; +import { formatAuthError, resolveToken } from './github/auth.ts'; +import { IoImpl } from './IoImpl.ts'; +import { createLogger } from './logger.ts'; + +const io = new IoImpl(); +const env = getEnvironment(); +const logger = createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); +const argv = process.argv.slice(2); +const isHelp = argv.includes('--help'); + +const telemetryDisabled = isHelp || isTelemetryDisabled(env); +const distinctId = telemetryDisabled ? '' : getOrCreateAnonymousId(env); +const analytics: Analytics = telemetryDisabled + ? new NoopAnalytics() + : new AnalyticsImpl({ distinctId, version: pkg.version }); +if (!telemetryDisabled) analytics.identify(distinctId); + +if (isHelp) { + const ctx = createContext({ token: '', io, env, logger, analytics }); + const code = await main(ctx, argv); + await analytics.shutdown(); + process.exit(code); +} + +const tokenResult = await resolveToken(); +if (tokenResult.isErr()) { + logger.error(formatAuthError(tokenResult.error)); + await analytics.shutdown(); + process.exit(1); +} + +const ctx = createContext({ token: tokenResult.value, io, env, logger, analytics }); +const code = await main(ctx, argv); +await analytics.shutdown(); +process.exit(code); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..dd9e2e0 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,19 @@ +import pino, { type LevelWithSilent, type Logger } from 'pino'; +import pinoPretty from 'pino-pretty'; + +export type { Logger, LevelWithSilent }; + +export interface CreateLoggerOptions { + readonly level: LevelWithSilent; + readonly destination: NodeJS.WritableStream & { isTTY?: boolean }; +} + +export function createLogger({ level, destination }: CreateLoggerOptions): Logger { + // pino-pretty as a sync stream (not a worker transport) so compiled + // single-file Bun binaries keep working — transport workers need runtime + // module resolution that bundled binaries can't satisfy. + const sink = destination.isTTY + ? pinoPretty({ colorize: true, destination: destination as NodeJS.WritableStream }) + : destination; + return pino({ level }, sink); +} diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts new file mode 100644 index 0000000..c39e8c4 --- /dev/null +++ b/src/report/aggregate.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from 'bun:test'; +import { + branchProtectionSlice, + collectedData, + collectionContext, + cveAlert, + cveSliceOk, + dependabotConfigSlice, + dependabotPr, + dependabotUpdateEntry, + repoMeta, +} from '../testFactories.ts'; +import { instantFromString } from '../time.ts'; +import { aggregate } from './aggregate.ts'; + +test('counts merged-in-window PRs and surfaces backlog age buckets', () => { + const data = collectedData.build({ + dependabotPrs: [ + dependabotPr.build({ + state: 'closed', + merged: true, + mergedAt: '2026-04-01T00:00:00Z', + createdAt: '2026-03-30T00:00:00Z', + }), + dependabotPr.build({ + state: 'open', + createdAt: '2025-09-01T00:00:00Z', + }), + ], + }); + + const bundle = aggregate(data); + expect(bundle.prBacklog).toMatchObject({ + openCount: 1, + mergedInWindowCount: 1, + oldestOpenDays: expect.any(Number) as number, + }); + // The 263-day-old PR should fall into the 180+ bucket. + const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days'); + expect(oldBucket?.count).toBe(1); +}); + +test('rolls org/visibility/language counts up into orgOverview', () => { + const data = collectedData.build({ + repos: [ + repoMeta.build({ name: 'a', visibility: 'public', primaryLanguage: 'TypeScript' }), + repoMeta.build({ name: 'b', visibility: 'private', primaryLanguage: 'JavaScript' }), + repoMeta.build({ name: 'c', visibility: 'private', primaryLanguage: 'Go' }), + repoMeta.build({ name: 'd', visibility: 'internal', primaryLanguage: null, archived: true }), + ], + branchProtection: [ + branchProtectionSlice.build({ name: 'a', hasProtection: true }), + branchProtectionSlice.build({ name: 'b', hasProtection: false }), + ], + }); + + const bundle = aggregate(data); + expect(bundle.orgOverview).toMatchObject({ + repoCount: 3, + publicCount: 1, + privateCount: 2, + archivedExcluded: 1, + nodeTsRepoCount: 2, + reposWithBranchProtection: 1, + }); +}); + +test('emits a scope-missing CVE exposure when any slice signals scope-missing', () => { + const data = collectedData.build({ + cve: [{ owner: 'acme', name: 'widgets', status: 'scope-missing', requiredScope: 'security_events' }], + }); + const bundle = aggregate(data); + expect(bundle.cve).toMatchObject({ status: 'scope-missing', requiredScope: 'security_events' }); +}); + +test('counts CVE alerts by severity and surfaces oldest critical days', () => { + const data = collectedData.build({ + ctx: collectionContext.build({ now: instantFromString('2026-05-22T00:00:00Z') }), + cve: [ + cveSliceOk.build({ + alerts: [ + cveAlert.build({ severity: 'critical', createdAt: '2026-01-01T00:00:00Z' }), + cveAlert.build({ severity: 'high' }), + cveAlert.build({ severity: 'high' }), + ], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.cve.status).toBe('ok'); + if (bundle.cve.status === 'ok') { + expect(bundle.cve.bySeverity).toEqual({ critical: 1, high: 2, medium: 0, low: 0 }); + expect(bundle.cve.oldestCriticalDays).toBeGreaterThanOrEqual(141); + } +}); + +test('aggregates failing check names across open PRs, sorted by frequency', () => { + const prA = dependabotPr.build({ + state: 'open', + checks: { + total: 2, + success: 0, + failure: 2, + pending: 0, + failedCheckNames: ['test (unit)', 'lint'], + }, + }); + const prB = dependabotPr.build({ + state: 'open', + checks: { + total: 2, + success: 1, + failure: 1, + pending: 0, + failedCheckNames: ['test (unit)'], + }, + }); + const prC = dependabotPr.build({ + state: 'open', + checks: { + total: 3, + success: 2, + failure: 1, + pending: 0, + failedCheckNames: ['typecheck', 'typecheck'], + }, + }); + + const bundle = aggregate(collectedData.build({ dependabotPrs: [prA, prB, prC] })); + expect(bundle.prBacklog.failingCheckBreakdown).toEqual([ + { checkName: 'test (unit)', failingPrCount: 2 }, + { checkName: 'lint', failingPrCount: 1 }, + { checkName: 'typecheck', failingPrCount: 1 }, + ]); +}); + +test('builds a cost estimate matrix with rate columns and time-per-PR rows', () => { + const data = collectedData.build({ + dependabotPrs: Array.from({ length: 100 }, (_, i) => + dependabotPr.build({ + number: i + 1, + state: 'closed', + merged: true, + mergedAt: '2026-04-01T00:00:00Z', + createdAt: '2026-03-30T00:00:00Z', + }), + ), + }); + const bundle = aggregate(data); + expect(bundle.costEstimate.mergedInWindow).toBe(100); + expect(bundle.costEstimate.hourlyRateUsd).toBe(150); + expect(bundle.costEstimate.minutesPerPr).toBe(5); + // 100 PRs × 5 min × $150/hr / 60 = $1250 in window + expect(bundle.costEstimate.windowCostUsd).toBe(1250); + // ~$423/month over 90 days (window × 30.44/90) + expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(400); + expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(450); + expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12); + expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]); + expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe( + (bundle.costEstimate.savingsScenarios[0]?.monthlySavingsUsd ?? 0) * 12, + ); +}); + +test('topMergers excludes bot logins and surfaces per-person weekly cost', () => { + const data = collectedData.build({ + dependabotPrs: [ + dependabotPr.build({ number: 1, merged: true, mergedAt: '2026-04-01T00:00:00Z', mergedBy: 'alice' }), + dependabotPr.build({ number: 2, merged: true, mergedAt: '2026-04-02T00:00:00Z', mergedBy: 'alice' }), + dependabotPr.build({ number: 3, merged: true, mergedAt: '2026-04-03T00:00:00Z', mergedBy: 'github-actions' }), + dependabotPr.build({ number: 4, merged: true, mergedAt: '2026-04-04T00:00:00Z', mergedBy: 'dependabot' }), + dependabotPr.build({ number: 5, merged: true, mergedAt: '2026-04-05T00:00:00Z', mergedBy: 'renovate-bot' }), + dependabotPr.build({ number: 6, merged: true, mergedAt: '2026-04-06T00:00:00Z', mergedBy: 'bob' }), + ], + }); + const bundle = aggregate(data); + expect(bundle.people.topMergers.map((m) => m.login)).toEqual(['alice', 'bob']); + const alice = bundle.people.topMergers.find((m) => m.login === 'alice'); + expect(alice?.count).toBe(2); + expect(alice?.windowCostUsd).toBeGreaterThan(0); + expect(alice?.annualCostUsd).toBeGreaterThan(alice?.windowCostUsd ?? 0); +}); + +test('cadenceBreakdown counts update entries by schedule interval', () => { + const data = collectedData.build({ + dependabotConfig: [ + dependabotConfigSlice.build({ + name: 'a', + updates: [ + dependabotUpdateEntry.build({ ecosystem: 'npm', interval: 'daily' }), + dependabotUpdateEntry.build({ ecosystem: 'github-actions', interval: 'weekly' }), + ], + }), + dependabotConfigSlice.build({ + name: 'b', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', interval: 'weekly' })], + }), + dependabotConfigSlice.build({ + name: 'c', + updates: [dependabotUpdateEntry.build({ ecosystem: 'docker', interval: null })], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage.cadenceBreakdown).toEqual([ + { interval: 'daily', entryCount: 1 }, + { interval: 'weekly', entryCount: 2 }, + { interval: 'unspecified', entryCount: 1 }, + ]); +}); + +test('reposUsingGroups and reposWithIgnoreRules count repos with any non-zero entry', () => { + const data = collectedData.build({ + dependabotConfig: [ + dependabotConfigSlice.build({ + name: 'a', + updates: [dependabotUpdateEntry.build({ groupCount: 2, ignoreCount: 0 })], + }), + dependabotConfigSlice.build({ + name: 'b', + updates: [ + dependabotUpdateEntry.build({ groupCount: 0, ignoreCount: 0 }), + dependabotUpdateEntry.build({ groupCount: 0, ignoreCount: 3 }), + ], + }), + dependabotConfigSlice.build({ + name: 'c', + updates: [dependabotUpdateEntry.build({ groupCount: 1, ignoreCount: 1 })], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage).toMatchObject({ + reposUsingGroups: 2, + reposWithIgnoreRules: 2, + }); +}); + +test("reposAtPrCap uses each repo's effective cap from sum of entry limits", () => { + const fiveOpenPrs = (name: string) => + Array.from({ length: 5 }, (_, i) => + dependabotPr.build({ name, number: i + 1, state: 'open', createdAt: '2026-05-01T00:00:00Z' }), + ); + const data = collectedData.build({ + dependabotConfig: [ + // repo with a single default-limit entry — cap is 5; 5 open trips it + dependabotConfigSlice.build({ + name: 'capped', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', openPullRequestsLimit: 5 })], + }), + // repo with a raised limit — cap is 20; 5 open should NOT trip it + dependabotConfigSlice.build({ + name: 'raised', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', openPullRequestsLimit: 20 })], + }), + ], + dependabotPrs: [...fiveOpenPrs('capped'), ...fiveOpenPrs('raised')], + }); + const bundle = aggregate(data); + expect(bundle.stalledSignals.reposAtPrCap).toEqual([{ repo: 'acme/capped', openPrs: 5 }]); +}); + +test('dependabotCoverage reflects only the live (non-archived) repos', () => { + const data = collectedData.build({ + repos: [ + repoMeta.build({ name: 'a', archived: false, dependabotSecurityUpdates: true }), + repoMeta.build({ name: 'b', archived: false, dependabotSecurityUpdates: false }), + repoMeta.build({ name: 'c', archived: true, dependabotSecurityUpdates: true }), + ], + dependabotConfig: [ + dependabotConfigSlice.build({ name: 'a', hasConfig: true, ecosystems: ['npm'] }), + dependabotConfigSlice.build({ name: 'b', hasConfig: false, ecosystems: [] }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage).toMatchObject({ + reposWithConfig: 1, + reposWithSecurityUpdates: 1, + }); +}); diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts new file mode 100644 index 0000000..8f7ed08 --- /dev/null +++ b/src/report/aggregate.ts @@ -0,0 +1,490 @@ +import { classifyBumpType, isDevDependencyBump } from '../heuristics/bumpType.ts'; +import { type Instant, Temporal, instantFromString } from '../time.ts'; +import type { + CollectedData, + CveAlert, + CveSeverity, + DependabotConfigSlice, + DependabotPr, + LanguageBytes, +} from '../types.ts'; +import { + ASSUMED_HOURLY_RATE_USD, + ASSUMED_MIN_PER_PR, + ASSUMED_MIN_PER_REVIEW, + deriveCostEstimate, + derivePersonCosts, +} from './costFormulas.ts'; + +export interface ReportBundle { + meta: ReportMeta; + orgOverview: OrgOverview; + dependabotCoverage: DependabotCoverage; + prBacklog: PrBacklog; + stalledSignals: StalledSignals; + people: People; + costEstimate: CostEstimate; + cve: CveExposure; +} + +export interface ReportMeta { + org: string; + windowDays: number; + generatedAt: Instant; + totalReposScanned: number; +} + +export interface OrgOverview { + repoCount: number; + publicCount: number; + privateCount: number; + internalCount: number; + archivedExcluded: number; + topLanguages: Array<{ language: string; bytes: number; percentage: number }>; + nodeTsRepoCount: number; + nodeTsRepoPercentage: number; + activeHumanCommitters: number; + reposWithBranchProtection: number; +} + +export type CadenceLabel = 'daily' | 'weekly' | 'monthly' | 'unspecified'; + +export interface DependabotCoverage { + reposWithConfig: number; + reposWithConfigPercentage: number; + reposWithSecurityUpdates: number; + reposWithSecurityUpdatesPercentage: number; + ecosystemBreakdown: Array<{ ecosystem: string; repoCount: number }>; + cadenceBreakdown: Array<{ interval: CadenceLabel; entryCount: number }>; + reposUsingGroups: number; + reposWithIgnoreRules: number; +} + +export interface PrBacklog { + openCount: number; + closedInWindowCount: number; + mergedInWindowCount: number; + openAgeBuckets: Array<{ label: string; count: number }>; + oldestOpenDays: number | null; + bumpTypeSplit: Array<{ bumpType: string; count: number; percentage: number }>; + devOnlyShare: { count: number; percentage: number }; + ciStatusMix: { green: number; failing: number; pending: number }; + failingCheckBreakdown: Array<{ checkName: string; failingPrCount: number }>; + timeToMergeP50Days: number | null; + timeToMergeP90Days: number | null; +} + +export interface StalledSignals { + reposAtPrCap: Array<{ repo: string; openPrs: number }>; + reposWithConfigButNoRecentPrs: string[]; +} + +export interface People { + topMergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + topReviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + topCommenters: Array<{ login: string; count: number }>; +} + +export interface CostEstimate { + mergedInWindow: number; + openCount: number; + windowDays: number; + hourlyRateUsd: number; + minutesPerPr: number; + windowCostUsd: number; + monthlyCostUsd: number; + annualCostUsd: number; + savingsScenarios: Array<{ autoMergeRate: number; monthlySavingsUsd: number; annualSavingsUsd: number }>; +} + +export interface CveExposure { + status: 'ok' | 'scope-missing' | 'no-data'; + requiredScope?: string; + totalOpenAlerts: number; + bySeverity: Record; + topReposBySeverity: Array<{ repo: string; critical: number; high: number; medium: number; low: number }>; + oldestCriticalDays: number | null; + oldestHighDays: number | null; + reposWithSecurityAlertsDisabled: string[]; +} + +const DEFAULT_PR_CAP = 5; + +export function aggregate(data: CollectedData): ReportBundle { + const now = data.ctx.now; + const windowStart = data.ctx.windowStart; + const meta: ReportMeta = { + org: data.ctx.org, + windowDays: data.ctx.windowDays, + generatedAt: now, + totalReposScanned: data.repos.length, + }; + + const orgOverview = buildOrgOverview(data); + const dependabotCoverage = buildDependabotCoverage(data); + const prBacklog = buildPrBacklog(data, now, windowStart); + const stalledSignals = buildStalledSignals(data, windowStart); + const people = buildPeople(data, data.ctx.windowDays); + const costEstimate = buildCostEstimate(prBacklog, data.ctx.windowDays); + const cve = buildCveExposure(data, now); + + return { + meta, + orgOverview, + dependabotCoverage, + prBacklog, + stalledSignals, + people, + costEstimate, + cve, + }; +} + +function buildOrgOverview(data: CollectedData): OrgOverview { + const repos = data.repos.filter((r) => !r.archived); + const archivedExcluded = data.repos.length - repos.length; + const publicCount = repos.filter((r) => r.visibility === 'public').length; + const privateCount = repos.filter((r) => r.visibility === 'private').length; + const internalCount = repos.filter((r) => r.visibility === 'internal').length; + + const aggregateBytes: LanguageBytes = {}; + for (const lang of data.languages) { + for (const [name, bytes] of Object.entries(lang.bytes)) { + aggregateBytes[name] = (aggregateBytes[name] ?? 0) + bytes; + } + } + const totalBytes = Object.values(aggregateBytes).reduce((a, b) => a + b, 0); + const topLanguages = Object.entries(aggregateBytes) + .map(([language, bytes]) => ({ + language, + bytes, + percentage: totalBytes > 0 ? round1((bytes / totalBytes) * 100) : 0, + })) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, 10); + + const nodeTsRepoCount = repos.filter( + (r) => r.primaryLanguage === 'TypeScript' || r.primaryLanguage === 'JavaScript', + ).length; + + const allCommitters = new Set(); + for (const slice of data.contributors) { + for (const login of slice.activeHumanLogins) allCommitters.add(login); + } + + const reposWithBranchProtection = data.branchProtection.filter((b) => b.hasProtection).length; + + return { + repoCount: repos.length, + publicCount, + privateCount, + internalCount, + archivedExcluded, + topLanguages, + nodeTsRepoCount, + nodeTsRepoPercentage: pct(nodeTsRepoCount, repos.length), + activeHumanCommitters: allCommitters.size, + reposWithBranchProtection, + }; +} + +function buildDependabotCoverage(data: CollectedData): DependabotCoverage { + const liveRepos = data.repos.filter((r) => !r.archived); + const reposWithConfig = data.dependabotConfig.filter((c) => c.hasConfig).length; + const reposWithSecurity = liveRepos.filter((r) => r.dependabotSecurityUpdates === true).length; + + const ecoCounts = new Map(); + for (const cfg of data.dependabotConfig) { + for (const eco of cfg.ecosystems) { + ecoCounts.set(eco, (ecoCounts.get(eco) ?? 0) + 1); + } + } + const ecosystemBreakdown = [...ecoCounts.entries()] + .map(([ecosystem, repoCount]) => ({ ecosystem, repoCount })) + .sort((a, b) => b.repoCount - a.repoCount); + + const cadenceCounts: Record = { daily: 0, weekly: 0, monthly: 0, unspecified: 0 }; + let reposUsingGroups = 0; + let reposWithIgnoreRules = 0; + for (const cfg of data.dependabotConfig) { + for (const update of cfg.updates) { + cadenceCounts[update.interval ?? 'unspecified'] += 1; + } + if (cfg.updates.some((u) => u.groupCount > 0)) reposUsingGroups += 1; + if (cfg.updates.some((u) => u.ignoreCount > 0)) reposWithIgnoreRules += 1; + } + const cadenceOrder: CadenceLabel[] = ['daily', 'weekly', 'monthly', 'unspecified']; + const cadenceBreakdown = cadenceOrder + .map((interval) => ({ interval, entryCount: cadenceCounts[interval] })) + .filter((c) => c.entryCount > 0); + + return { + reposWithConfig, + reposWithConfigPercentage: pct(reposWithConfig, liveRepos.length), + reposWithSecurityUpdates: reposWithSecurity, + reposWithSecurityUpdatesPercentage: pct(reposWithSecurity, liveRepos.length), + ecosystemBreakdown, + cadenceBreakdown, + reposUsingGroups, + reposWithIgnoreRules, + }; +} + +function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant): PrBacklog { + const prs = data.dependabotPrs; + const openPrs = prs.filter((p) => p.state === 'open'); + const mergedInWindow = prs.filter((p) => p.merged && p.mergedAt && isAtOrAfter(p.mergedAt, windowStart)); + const closedNotMergedInWindow = prs.filter((p) => !p.merged && p.closedAt && isAtOrAfter(p.closedAt, windowStart)); + + const buckets = [ + { label: '0–30 days', min: 0, max: 30 }, + { label: '30–60 days', min: 30, max: 60 }, + { label: '60–90 days', min: 60, max: 90 }, + { label: '90–180 days', min: 90, max: 180 }, + { label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY }, + ]; + const openAgeBuckets = buckets.map((b) => ({ + label: b.label, + count: openPrs.filter((p) => { + const age = daysBetween(now, instantFromString(p.createdAt)); + return age >= b.min && age < b.max; + }).length, + })); + + const oldestOpenDays = + openPrs.length === 0 ? null : Math.max(...openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt)))); + + const bumpCounts = new Map(); + for (const pr of prs) { + const t = classifyBumpType(pr.title); + bumpCounts.set(t, (bumpCounts.get(t) ?? 0) + 1); + } + const bumpTotal = [...bumpCounts.values()].reduce((a, b) => a + b, 0); + const bumpTypeSplit = [...bumpCounts.entries()] + .map(([bumpType, count]) => ({ bumpType, count, percentage: pct(count, bumpTotal) })) + .sort((a, b) => b.count - a.count); + + const devOnlyCount = prs.filter((p) => isDevDependencyBump(p.title)).length; + const devOnlyShare = { count: devOnlyCount, percentage: pct(devOnlyCount, prs.length) }; + + let green = 0; + let failing = 0; + let pending = 0; + const failingPrCountByCheck = new Map(); + for (const pr of openPrs) { + if (pr.checks.total === 0) { + pending += 1; + continue; + } + if (pr.checks.failure > 0) { + failing += 1; + const uniqueNames = new Set(pr.checks.failedCheckNames); + for (const name of uniqueNames) { + failingPrCountByCheck.set(name, (failingPrCountByCheck.get(name) ?? 0) + 1); + } + } else if (pr.checks.pending > 0) { + pending += 1; + } else { + green += 1; + } + } + const failingCheckBreakdown = [...failingPrCountByCheck.entries()] + .map(([checkName, failingPrCount]) => ({ checkName, failingPrCount })) + .sort((a, b) => b.failingPrCount - a.failingPrCount || a.checkName.localeCompare(b.checkName)); + + const ttMergeDays: number[] = mergedInWindow + .filter((p) => p.mergedAt !== null) + .map((p) => daysBetween(instantFromString(p.mergedAt as string), instantFromString(p.createdAt))); + ttMergeDays.sort((a, b) => a - b); + const timeToMergeP50Days = percentile(ttMergeDays, 50); + const timeToMergeP90Days = percentile(ttMergeDays, 90); + + return { + openCount: openPrs.length, + closedInWindowCount: closedNotMergedInWindow.length, + mergedInWindowCount: mergedInWindow.length, + openAgeBuckets, + oldestOpenDays, + bumpTypeSplit, + devOnlyShare, + ciStatusMix: { green, failing, pending }, + failingCheckBreakdown, + timeToMergeP50Days, + timeToMergeP90Days, + }; +} + +function buildStalledSignals(data: CollectedData, windowStart: Instant): StalledSignals { + const openByRepo = new Map(); + for (const pr of data.dependabotPrs) { + if (pr.state !== 'open') continue; + const key = `${pr.owner}/${pr.name}`; + const list = openByRepo.get(key) ?? []; + list.push(pr); + openByRepo.set(key, list); + } + const configByRepo = new Map(); + for (const cfg of data.dependabotConfig) { + configByRepo.set(`${cfg.owner}/${cfg.name}`, cfg); + } + const reposAtPrCap = [...openByRepo.entries()] + .filter(([repo, list]) => list.length >= effectivePrCap(configByRepo.get(repo))) + .map(([repo, list]) => ({ repo, openPrs: list.length })) + .sort((a, b) => b.openPrs - a.openPrs); + + const repoToRecentPrs = new Map(); + for (const pr of data.dependabotPrs) { + if (!isAtOrAfter(pr.createdAt, windowStart)) continue; + const key = `${pr.owner}/${pr.name}`; + repoToRecentPrs.set(key, (repoToRecentPrs.get(key) ?? 0) + 1); + } + const reposWithConfigButNoRecentPrs = data.dependabotConfig + .filter((c) => c.hasConfig) + .filter((c) => (repoToRecentPrs.get(`${c.owner}/${c.name}`) ?? 0) === 0) + .map((c) => `${c.owner}/${c.name}`) + .sort(); + + return { + reposAtPrCap, + reposWithConfigButNoRecentPrs, + }; +} + +function buildPeople(data: CollectedData, windowDays: number): People { + const mergerCounts = new Map(); + const reviewerCounts = new Map(); + const commenterCounts = new Map(); + for (const pr of data.dependabotPrs) { + if (pr.mergedBy && !isBotLogin(pr.mergedBy)) { + mergerCounts.set(pr.mergedBy, (mergerCounts.get(pr.mergedBy) ?? 0) + 1); + } + for (const r of pr.reviewers) if (!isBotLogin(r)) reviewerCounts.set(r, (reviewerCounts.get(r) ?? 0) + 1); + for (const c of pr.commenters) if (!isBotLogin(c)) commenterCounts.set(c, (commenterCounts.get(c) ?? 0) + 1); + } + const top = (m: Map) => + [...m.entries()] + .map(([login, count]) => ({ login, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const topMergers = derivePersonCosts(top(mergerCounts), windowDays, ASSUMED_MIN_PER_PR, ASSUMED_HOURLY_RATE_USD); + const topReviewers = derivePersonCosts( + top(reviewerCounts), + windowDays, + ASSUMED_MIN_PER_REVIEW, + ASSUMED_HOURLY_RATE_USD, + ); + + return { + topMergers, + topReviewers, + topCommenters: top(commenterCounts), + }; +} + +function buildCostEstimate(prBacklog: PrBacklog, windowDays: number): CostEstimate { + const merged = prBacklog.mergedInWindowCount; + const derived = deriveCostEstimate(merged, windowDays, { + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + }); + return { + mergedInWindow: merged, + openCount: prBacklog.openCount, + windowDays, + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + ...derived, + }; +} + +const BOT_LOGIN_RE = /(\[bot\]$|^dependabot$|^github-actions$|-bot$|^copilot$|^renovate$)/i; + +export function isBotLogin(login: string): boolean { + return BOT_LOGIN_RE.test(login); +} + +function buildCveExposure(data: CollectedData, now: Instant): CveExposure { + const scopeMissing = data.cve.find((s) => s.status === 'scope-missing'); + if (scopeMissing && scopeMissing.status === 'scope-missing') { + return { + status: 'scope-missing', + requiredScope: scopeMissing.requiredScope, + totalOpenAlerts: 0, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + topReposBySeverity: [], + oldestCriticalDays: null, + oldestHighDays: null, + reposWithSecurityAlertsDisabled: [], + }; + } + + const okSlices = data.cve.filter((s) => s.status === 'ok'); + const disabledRepos = data.cve.filter((s) => s.status === 'not-enabled').map((s) => `${s.owner}/${s.name}`); + const allAlerts: CveAlert[] = okSlices.flatMap((s) => (s.status === 'ok' ? s.alerts : [])); + const bySeverity: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const a of allAlerts) bySeverity[a.severity] += 1; + + const byRepo = new Map>(); + for (const a of allAlerts) { + const key = `${a.owner}/${a.name}`; + let rec = byRepo.get(key); + if (!rec) { + rec = { critical: 0, high: 0, medium: 0, low: 0 }; + byRepo.set(key, rec); + } + rec[a.severity] += 1; + } + const topReposBySeverity = [...byRepo.entries()] + .map(([repo, counts]) => ({ repo, ...counts })) + .sort((a, b) => severityScore(b) - severityScore(a)) + .slice(0, 5); + + const oldest = (sev: CveSeverity): number | null => { + const filtered = allAlerts.filter((a) => a.severity === sev); + if (filtered.length === 0) return null; + return Math.max(...filtered.map((a) => daysBetween(now, instantFromString(a.createdAt)))); + }; + + return { + status: 'ok', + totalOpenAlerts: allAlerts.length, + bySeverity, + topReposBySeverity, + oldestCriticalDays: oldest('critical'), + oldestHighDays: oldest('high'), + reposWithSecurityAlertsDisabled: disabledRepos, + }; +} + +function severityScore(rec: { critical: number; high: number; medium: number; low: number }): number { + return rec.critical * 1000 + rec.high * 100 + rec.medium * 10 + rec.low; +} + +function effectivePrCap(config: DependabotConfigSlice | undefined): number { + if (!config || config.updates.length === 0) return DEFAULT_PR_CAP; + return config.updates.reduce((sum, u) => sum + u.openPullRequestsLimit, 0); +} + +function pct(numerator: number, denominator: number): number { + if (denominator === 0) return 0; + return round1((numerator / denominator) * 100); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +function daysBetween(later: Instant, earlier: Instant): number { + return Math.max(0, Math.floor((later.epochMilliseconds - earlier.epochMilliseconds) / 86_400_000)); +} + +function isAtOrAfter(iso: string, target: Instant): boolean { + return Temporal.Instant.compare(instantFromString(iso), target) >= 0; +} + +function percentile(sorted: readonly number[], p: number): number | null { + if (sorted.length === 0) return null; + const rank = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(rank, sorted.length - 1))] ?? null; +} diff --git a/src/report/bundle.test.ts b/src/report/bundle.test.ts new file mode 100644 index 0000000..2964de9 --- /dev/null +++ b/src/report/bundle.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test'; +import { strFromU8, unzipSync } from 'fflate'; +import { collectedData, dependabotPr } from '../testFactories.ts'; +import { aggregate } from './aggregate.ts'; +import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './bundle.ts'; + +const meta: BundleMeta = { + cliVersion: '0.0.1', + generatedAt: '2026-05-22T00:00:00Z', + target: 'acme', + windowDays: 90, + windowStart: '2026-02-21T00:00:00Z', + options: { include: null, exclude: [] }, + counts: { reposTotal: 1, reposIncluded: 1, dependabotPrs: 1, warnings: 0 }, +}; + +test('buildBundleFiles emits one JSON file per slice plus the html report and README', () => { + const collected = collectedData.build({ dependabotPrs: [dependabotPr.build()] }); + const aggregated = aggregate(collected); + const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: 'hi' }); + + expect(Object.keys(files).sort()).toEqual( + [ + 'README.txt', + 'data/aggregated.json', + 'data/branch-protection.json', + 'data/contributors.json', + 'data/cve.json', + 'data/dependabot-config.json', + 'data/dependabot-prs.json', + 'data/languages.json', + 'data/meta.json', + 'data/repos.json', + 'data/warnings.json', + 'patchwave-report.html', + ].sort(), + ); + expect(files['patchwave-report.html']).toBe('hi'); + expect(files['README.txt']).toContain('patchwave-analysis bundle'); +}); + +test('data JSON files are pretty-printed and slice-shaped', () => { + const collected = collectedData.build({ dependabotPrs: [dependabotPr.build({ number: 42 })] }); + const aggregated = aggregate(collected); + const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: '' }); + + const repos = JSON.parse(files['data/repos.json'] as string) as unknown; + expect(Array.isArray(repos)).toBe(true); + + const prs = JSON.parse(files['data/dependabot-prs.json'] as string) as Array<{ number: number }>; + expect(prs[0]?.number).toBe(42); + + expect(files['data/aggregated.json']).toContain(' '); // indented +}); + +test('zipBundleFiles round-trips through unzipSync', () => { + const zip = zipBundleFiles({ 'patchwave-report.html': '', 'data/meta.json': '{}\n' }); + const entries = unzipSync(zip); + expect(strFromU8(entries['patchwave-report.html'] as Uint8Array)).toBe(''); + expect(strFromU8(entries['data/meta.json'] as Uint8Array)).toBe('{}\n'); +}); diff --git a/src/report/bundle.ts b/src/report/bundle.ts new file mode 100644 index 0000000..b64cdd7 --- /dev/null +++ b/src/report/bundle.ts @@ -0,0 +1,87 @@ +import { strToU8, zipSync } from 'fflate'; +import type { CollectedData } from '../types.ts'; +import type { ReportBundle } from './aggregate.ts'; + +export interface BundleMeta { + cliVersion: string; + generatedAt: string; + target: string; + windowDays: number; + windowStart: string; + options: { + include: string[] | null; + exclude: string[]; + }; + counts: { + reposTotal: number; + reposIncluded: number; + dependabotPrs: number; + warnings: number; + }; +} + +export interface BundleInputs { + meta: BundleMeta; + collected: CollectedData; + aggregated: ReportBundle; + reportHtml: string; +} + +export function buildBundleFiles(inputs: BundleInputs): Record { + const { meta, collected, aggregated, reportHtml } = inputs; + return { + 'patchwave-report.html': reportHtml, + 'README.txt': sharebackReadme(meta), + 'data/meta.json': stringify(meta), + 'data/repos.json': stringify(collected.repos), + 'data/languages.json': stringify(collected.languages), + 'data/dependabot-config.json': stringify(collected.dependabotConfig), + 'data/dependabot-prs.json': stringify(collected.dependabotPrs), + 'data/cve.json': stringify(collected.cve), + 'data/branch-protection.json': stringify(collected.branchProtection), + 'data/contributors.json': stringify(collected.contributors), + 'data/warnings.json': stringify(collected.errors), + 'data/aggregated.json': stringify(aggregated), + }; +} + +export function zipBundleFiles(files: Record): Uint8Array { + const entries: Record = {}; + for (const [path, contents] of Object.entries(files)) { + entries[path] = strToU8(contents); + } + return zipSync(entries); +} + +function stringify(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function sharebackReadme(meta: BundleMeta): string { + return [ + `patchwave-analysis bundle`, + ``, + `Generated: ${meta.generatedAt}`, + `Target: ${meta.target}`, + `Window: ${meta.windowDays} days (since ${meta.windowStart})`, + `CLI: v${meta.cliVersion}`, + ``, + `Contents:`, + ` patchwave-report.html — interactive report (open in any browser)`, + ` data/meta.json — run metadata`, + ` data/aggregated.json — rolled-up metrics that drive the report`, + ` data/repos.json — repo metadata`, + ` data/languages.json — per-repo language byte counts`, + ` data/dependabot-config.json — per-repo Dependabot config + ecosystems`, + ` data/dependabot-prs.json — Dependabot PRs in the window (state, checks, reviewers)`, + ` data/cve.json — Dependabot security alert slices`, + ` data/branch-protection.json — default-branch protection slices`, + ` data/contributors.json — active human committers per repo`, + ` data/warnings.json — per-collector warnings suppressed during the crawl`, + ``, + `Sharing this zip with contextbridge gives us the same view the report does plus the raw`, + `numbers behind every metric. No tokens, secrets, or file contents leave your machine`, + `unless you choose to share this archive.`, + ``, + ].join('\n'); +} diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts new file mode 100644 index 0000000..225a0af --- /dev/null +++ b/src/report/costFormulas.ts @@ -0,0 +1,94 @@ +// Pure cost math shared between the server-side aggregator and the client-side +// React app. No Temporal, no Bun globals, no React — keep this module dependency-free +// so it bundles cleanly into both targets. + +export const ASSUMED_HOURLY_RATE_USD = 150; +export const ASSUMED_MIN_PER_PR = 5; +export const ASSUMED_MIN_PER_REVIEW = 3; + +export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const; + +const DAYS_PER_MONTH = 365 / 12; + +export function windowCostFor(count: number, minutesPerAction: number, hourlyRateUsd: number): number { + return Math.round(((count * minutesPerAction) / 60) * hourlyRateUsd); +} + +export function monthlyFromWindow(windowCostUsd: number, windowDays: number): number { + return Math.round((windowCostUsd * DAYS_PER_MONTH) / Math.max(1, windowDays)); +} + +export function annualizeWindow(windowCostUsd: number, windowDays: number): number { + return Math.round((windowCostUsd * 365) / Math.max(1, windowDays)); +} + +export interface SavingsScenario { + autoMergeRate: number; + monthlySavingsUsd: number; + annualSavingsUsd: number; +} + +export interface CostAssumptions { + hourlyRateUsd: number; + minutesPerPr: number; +} + +export interface DerivedCostEstimate { + windowCostUsd: number; + monthlyCostUsd: number; + annualCostUsd: number; + savingsScenarios: SavingsScenario[]; +} + +export interface CountedPerson { + login: string; + count: number; +} + +export interface DerivedPersonCost extends CountedPerson { + windowCostUsd: number; + annualCostUsd: number; +} + +export function savingsScenariosFor(monthlyCostUsd: number): SavingsScenario[] { + return AUTO_MERGE_SCENARIO_RATES.map((autoMergeRate) => { + const monthlySavingsUsd = Math.round(monthlyCostUsd * autoMergeRate); + return { + autoMergeRate, + monthlySavingsUsd, + annualSavingsUsd: monthlySavingsUsd * 12, + }; + }); +} + +export function deriveCostEstimate( + count: number, + windowDays: number, + assumptions: CostAssumptions, +): DerivedCostEstimate { + const windowCostUsd = windowCostFor(count, assumptions.minutesPerPr, assumptions.hourlyRateUsd); + const monthlyCostUsd = monthlyFromWindow(windowCostUsd, windowDays); + return { + windowCostUsd, + monthlyCostUsd, + annualCostUsd: monthlyCostUsd * 12, + savingsScenarios: savingsScenariosFor(monthlyCostUsd), + }; +} + +export function derivePersonCosts( + people: readonly CountedPerson[], + windowDays: number, + minutesPerAction: number, + hourlyRateUsd: number, +): DerivedPersonCost[] { + return people.map(({ login, count }) => { + const windowCostUsd = windowCostFor(count, minutesPerAction, hourlyRateUsd); + return { + login, + count, + windowCostUsd, + annualCostUsd: annualizeWindow(windowCostUsd, windowDays), + }; + }); +} diff --git a/src/report/embeddedShape.ts b/src/report/embeddedShape.ts new file mode 100644 index 0000000..2cfdbf6 --- /dev/null +++ b/src/report/embeddedShape.ts @@ -0,0 +1,15 @@ +import type { ReportBundle, ReportMeta } from './aggregate.ts'; + +export interface EmbeddedReportData extends Omit { + meta: Omit & { generatedAt: string }; +} + +// The web report consumes the bundle with `generatedAt` serialized to a string. +// This module is intentionally free of runtime imports (types only) so it is safe +// to pull into the browser bundle without dragging in html.ts's embedded template. +export function toEmbeddedShape(bundle: ReportBundle): EmbeddedReportData { + return { + ...bundle, + meta: { ...bundle.meta, generatedAt: bundle.meta.generatedAt.toString() }, + }; +} diff --git a/src/report/html.test.ts b/src/report/html.test.ts new file mode 100644 index 0000000..f455423 --- /dev/null +++ b/src/report/html.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { renderHtmlFrom, toEmbeddedShape } from './html.ts'; +import { reportBundle, reportMeta } from './testFactories.ts'; + +const STUB_TEMPLATE = + ''; + +const LINE_SEP = String.fromCharCode(0x2028); +const PARA_SEP = String.fromCharCode(0x2029); + +describe('toEmbeddedShape', () => { + test('converts Instant generatedAt to an ISO string', () => { + const bundle = reportBundle.build(); + const shape = toEmbeddedShape(bundle); + expect(typeof shape.meta.generatedAt).toBe('string'); + expect(shape.meta.generatedAt).toBe(bundle.meta.generatedAt.toString()); + }); + + test('preserves all other slices verbatim', () => { + const bundle = reportBundle.build(); + const shape = toEmbeddedShape(bundle); + expect(shape.orgOverview).toEqual(bundle.orgOverview); + expect(shape.dependabotCoverage).toEqual(bundle.dependabotCoverage); + expect(shape.prBacklog).toEqual(bundle.prBacklog); + expect(shape.stalledSignals).toEqual(bundle.stalledSignals); + expect(shape.people).toEqual(bundle.people); + expect(shape.costEstimate).toEqual(bundle.costEstimate); + expect(shape.cve).toEqual(bundle.cve); + }); +}); + +describe('renderHtmlFrom', () => { + test('substitutes the placeholder and embeds parseable JSON', () => { + const bundle = reportBundle.build(); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + expect(result.isOk()).toBe(true); + const rendered = result.unwrapOr(''); + + expect(rendered).not.toContain('__PATCHWAVE_DATA__'); + expect(rendered).toContain(' sequences inside the embedded JSON', () => { + const bundle = reportBundle.build({ + meta: reportMeta.build({ org: 'malicious' }), + }); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + expect(result.isOk()).toBe(true); + const rendered = result.unwrapOr(''); + const scriptCloses = rendered.match(/<\/script>/gi) ?? []; + expect(scriptCloses).toHaveLength(1); + expect(rendered).toContain('<\\/script>'); + }); + + test('escapes