diff --git a/.agents/skills/upgrade-packages/REFERENCE.md b/.agents/skills/upgrade-packages/REFERENCE.md index a4a69d03..05009a36 100644 --- a/.agents/skills/upgrade-packages/REFERENCE.md +++ b/.agents/skills/upgrade-packages/REFERENCE.md @@ -1,6 +1,10 @@ # Reference — evidence artifact schema -The `upgrade-packages` skill runs `bun run upgrade-packages:evidence` (writes `scripts/upgrade-packages/artifact.json`) and reads the JSON artifact below. The agent never touches the registry, GitHub, or GHSA directly — every citation comes from artifact fields. The script requires network access (`gh api` + `bun pm view`); release/advisory data is cached to `scripts/upgrade-packages/.cache/` (1h TTL). +The skill runs `bun run upgrade-packages:evidence` (default `--out scripts/upgrade-packages/artifact.json`). Phase 1 of [`SKILL.md`](./SKILL.md) owns network, cache TTLs, and the never-touch-registry rule. + +Each outdated package gets **one** delta: the published tarball span `current → latest`. There is no per-tag GitHub release walk. + +Cutter caps (`tarball-delta.ts`): `PATCH_PATH_CAP` 40 paths on the second `bun pm diff`, `FILE_LIST_CAP` 80 files in the artifact, `HINT_CAP` 8 hints per bucket, `PATCH_MAX_CHARS` 8000, `RELEASE_NOTES_MAX_CHARS` 1200. A missing `*.d.ts` patch means it lost the cap, not that the API is unchanged. `source: "none"` sets `tarball: null`. ## Artifact shape @@ -14,7 +18,8 @@ The `upgrade-packages` skill runs `bun run upgrade-packages:evidence` (writes `s "outdated": [{ "pkg", "current", "latest", "bumpClass": "patch|minor|major|prerelease|no-op", - "coupledWith": [] // naive; agent confirms peer/dep coupling from deltas + "coupledWith": [], // naive; agent confirms peer/dep coupling from deltas + "dev" }], "audit": { "bunAudit": , @@ -29,13 +34,19 @@ The `upgrade-packages` skill runs `bun run upgrade-packages:evidence` (writes `s }, "deltas": { "": [{ - "version", "date", + "version", "date", // target; date from changelog hunk if present "breaking": [...], "deprecations": [...], "features": [...], - "security": [...], "peerEngine": [...], // best-effort regex hints — read releaseNotes for the authoritative text - "releaseNotes": "truncated body", - "diffUrl": "github.com///compare/...", - "changelogUrl": "github.com///releases/tag/", - "source": "github-release|none", + "security": [...], "peerEngine": [...], // notes + changelog/package.json hints + "releaseNotes": "changelog added-lines, truncated", + "changelogUrl": "npmjs.com/package//v/", + "tarball": { + "from", "to", + "notes": [...], // bun summary: engines, deps, install scripts, dangerous imports + "totals": { "files", "added", "deleted", "linesAdded", "linesRemoved", "formattingOnly" }, + "files": [{ "path", "status", "linesAdded", "linesRemoved", "formattingOnly", "patch" }] + // patch only for paths that won PATCH_PATH_CAP (named keep + symbol-boosted .d.ts + extras) + }, + "source": "bun-pm-diff|none", "error": null | "reason" }] }, @@ -52,8 +63,8 @@ The `upgrade-packages` skill runs `bun run upgrade-packages:evidence` (writes `s ## How to read it -- **Verdict a package**: read `outdated[].bumpClass` + `audit.ghsa[].verdict` + `deltas[][].breaking`/`security` + `usage[].importedSymbols`. Cite `diffUrl` or `changelogUrl` for every claim. -- **`features`/`breaking` arrays are hints** — when a hint is empty but the delta is minor/major, read `releaseNotes` before concluding "no changes". -- **`error` on a delta** means the script couldn't fetch that version's release notes — usually a gh secondary rate-limit during a large multi-repo run, or a monorepo squashed release. `changelogUrl` is still provided (the repo's releases page) — **deep-dive it per-package** when `releaseNotes` is null. Only mark the bump **blocked** if the deep-dive still can't cover the range. +- **Verdict a package**: read `outdated[].bumpClass` + `audit.ghsa[].verdict` + `tarball.notes` + `deltas[][].breaking`/`security` + `usage[].importedSymbols`. Cite `tarball.notes`, a kept `patch`, `changelogUrl`, or advisory `url`. +- **`features`/`breaking` arrays are hints** — when a hint is empty but the delta is minor/major, read `releaseNotes` and kept patches before concluding "no changes". +- **`error` on a delta** means `bun pm diff` failed for that span. `changelogUrl` is still the npm version page. Re-run evidence before marking **blocked**. - **`cleared-at-current`** = the GHSA advisory's fix already ships at the installed version — no bump needed, record the URL as evidence. -- **Citations are artifact fields** — `diffUrl`, `changelogUrl`, `url`. Do not invent URLs. +- **Citations are artifact fields** — `tarball.notes`, kept `tarball.files[].patch`, `changelogUrl`, `url`. Do not invent GitHub compare URLs. diff --git a/.agents/skills/upgrade-packages/SKILL.md b/.agents/skills/upgrade-packages/SKILL.md index b4982207..67dec334 100644 --- a/.agents/skills/upgrade-packages/SKILL.md +++ b/.agents/skills/upgrade-packages/SKILL.md @@ -6,13 +6,13 @@ description: >- # Upgrade packages -A script gathers every release delta, GHSA advisory, and codebase-usage site into one JSON artifact; you read it and judge. **Never touch the registry, GitHub, or GHSA directly** — every citation comes from the artifact (`diffUrl`, `changelogUrl`, advisory `url`), so no model priors sneak in. Sources of truth: the artifact, then the codebase. Cite where you read every claim. +A script gathers every tarball delta (`bun pm diff`), GHSA advisory, and codebase-usage site into one JSON artifact; you read it and judge. **Never touch the registry, GitHub, or GHSA directly** — every citation comes from the artifact (`tarball.notes`, kept `tarball.files[].patch`, `changelogUrl`, advisory `url`), so no model priors sneak in. Sources of truth: the artifact, then the codebase. Cite where you read every claim. This repo uses **bun** with **exact** pins in `package.json` — `bun update` alone bumps nothing. Lift an exact pin with `bun update @` (or `bun update --latest` per package) / a `package.json` edit. Never `bun update --latest` across the board. The `check-updates` script is inventory-only. ## Phase 1 — Gather evidence -Run `bun run upgrade-packages:evidence` (defaults to `--out scripts/upgrade-packages/artifact.json`; pass `--out ` to override). **Requires network access** — the script calls `gh api` (GitHub releases + GHSA advisories) and `bun pm view`; a sandboxed network allowlist that blocks `api.github.com` will make every gh call fail (the artifact degrades to `error` markers with `changelogUrl` deep-dive links). Release/advisory data is cached to `scripts/upgrade-packages/.cache/` (1h TTL) so re-runs are fast and don't re-trip rate limits. Read the artifact. Schema + how-to-read: [`REFERENCE.md`](./REFERENCE.md). +Run `bun run upgrade-packages:evidence` (defaults to `--out scripts/upgrade-packages/artifact.json`; pass `--out ` to override). **Requires network access** — the script calls `bun pm diff` (registry tarballs) and `gh api` (GHSA advisories only). A sandbox that blocks `api.github.com` fails GHSA (`check-failed`); deltas still land. Cache: `scripts/upgrade-packages/.cache/` (GHSA 1h, tarball diffs 7d). Read the artifact. Schema + how-to-read: [`REFERENCE.md`](./REFERENCE.md). **Done when:** the artifact exists and you've read `inventory`, `outdated`, `audit`, `deltas`, and `usage`. @@ -22,17 +22,17 @@ For each `outdated` package, produce a cited verdict + band: - **band** = `bumpClass` (patch/minor/major/prerelease). **Coupled deps:** if a patch bump's peer/dep requires a minor+ bump of another direct dep (check `deltas[][].peerEngine` + the other package's `bumpClass`), move the coupled set up a band. - **priority-bump** if `audit.ghsa` verdict is `priority-bump` — goes first within its band. -- **check-failed** if `audit.ghsa` verdict is `check-failed` (gh/parse/cache failure, `id:"error"`) — inconclusive, not a vuln; re-run evidence or deep-dive the `changelogUrl` before treating as blocked. -- **blocked** if any `deltas[][].error` leaves the range uncovered and the missing version can't be sourced via its `diffUrl`, OR a `breaking`/`security`/`peerEngine` delta is a break-risk you can't resolve (see Phase 3). +- **check-failed** if `audit.ghsa` verdict is `check-failed` (gh/parse/cache failure, `id:"error"`) — inconclusive, not a vuln; re-run evidence before treating as blocked. +- **blocked** if any `deltas[][].error` leaves the span uncovered (retry evidence; `changelogUrl` is the npm version page) OR a `breaking`/`security`/`peerEngine` delta is a break-risk you can't resolve (see Phase 3). - **deferred-major** for a major with an unresolved break — unless it clears a high/critical advisory, in which case surface the tradeoff to the user. -Cite the `diffUrl`/`changelogUrl`/advisory `url` for every verdict. +Cite `tarball.notes` / kept patches / `changelogUrl` / advisory `url` for every verdict. **Done when:** every outdated package has a cited verdict, a band, and a coupled-set tag; every `priority-bump` is flagged for Phase 3. ## Phase 3 — Fact-check break-risk against the codebase -For every **break-risk delta** (`breaking`, `deprecations`, `peerEngine`, or a behavior-changing fix in `security`/`features` — e.g. callback debounce, CVE patch altering semantics), cross-check `usage[]`. Use `callSites` (codemap) for **blast radius** — where the symbol is actually called, not just imported; `importedSymbols` + `typeOnlySymbols` for what's in scope; `sites` for import locations. Classify: **no usage** / **code-aligned** / **breaks** (needs a code change first). Cite `callSites`/`sites` (file:line). A `breaks` with no code change → the bump is **blocked** or **deferred**. +For every **break-risk delta** (`breaking`, `deprecations`, `peerEngine`, or a behavior-changing fix in `security`/`features` — e.g. callback debounce, CVE patch altering semantics), read `tarball.notes` plus kept patches (`package.json`, `*.d.ts`, changelog, top-churn extras) and cross-check `usage[]`. Use `callSites` (codemap) for **blast radius** — where the symbol is actually called, not just imported; `importedSymbols` + `typeOnlySymbols` for what's in scope; `sites` for import locations. Classify: **no usage** / **code-aligned** / **breaks** (needs a code change first). Cite `callSites`/`sites` (file:line). A `breaks` with no code change → the bump is **blocked** or **deferred**. **Done when:** every break-risk delta has a citation-backed `no usage | aligned | breaks` verdict; every `breaks` has a proposed code change. @@ -50,15 +50,15 @@ A bumped parser/resolver/CSS package can change extraction output — re-run `bu ## Phase 5 — Verify (local CI mirror) -Run what `.github/workflows/ci.yml` runs — `ci.yml` is the SSOT: +`package.json` `check` and `.github/workflows/ci.yml` are the SSOT. Do not restate their job lists here. -- `bun run check` → build + format:check + lint:ci + test + test:scripts + typecheck + test:golden + test:agent-eval (5 of 6 CI jobs: Format, Lint, Typecheck, Test, Build) -- `bun run build` → covered by `check`, but re-run explicitly if a bump only touched build tooling (tsdown/oxc) — **do not skip**; dep upgrades break the bundler/codegen far more often than types -- `bun audit` → CI's audit job blocks on **high/critical** (it greps `bun audit` output for `high:` / `critical:`); treat any high/critical advisory as **blocking-with-triage**, lower severities as documented +- `bun run check` +- `bun run build` — **do not skip** — dep upgrades break the bundler/codegen far more often than types +- `bun audit` — CI's audit job blocks on **high/critical**; treat a red high/critical audit as **blocking-with-triage**, lower severities as documented ## Phase 6 — Report -Produce: **security** (advisories → verdict, with GHSA id + URL + fixed-in), **consolidated changeset** (rolled up from `deltas` — per package `current → target`, bucketed breaking/deprecations/features/fixes/peer-engine, one line per delta), **adoption opportunities** (top ~5 `features` deltas the codebase isn't using — `usage` verdict + file:line + one-line why-adopt + follow-up; non-blocking), bumped packages (band → version → why-safe), deferred/blocked (cited reason + file:line), verification results. Every citation from the artifact. If the user asked to commit/PR, hand off to [`harden-pr`](../harden-pr/SKILL.md) full mode. +Produce: **security** (advisories → verdict, with GHSA id + URL + fixed-in), **consolidated changeset** (rolled up from `deltas` — per package `current → target` from the single tarball span, bucketed breaking/deprecations/features/fixes/peer-engine, one line per hint or note), **adoption opportunities** (top ~5 `features` deltas the codebase isn't using — `usage` verdict + file:line + one-line why-adopt + follow-up; non-blocking), bumped packages (band → version → why-safe), deferred/blocked (cited reason + file:line), verification results. Every citation from the artifact. If the user asked to commit/PR, hand off to [`harden-pr`](../harden-pr/SKILL.md) full mode. **Done when:** report accounts for every non-no-op package and advisory; every citation is real — no `possibly`, `likely`, or unstated assumptions. @@ -67,6 +67,9 @@ Produce: **security** (advisories → verdict, with GHSA id + URL + fixed-in), * - ❌ Touching the registry/GitHub/GHSA directly — run the script, read the artifact. - ❌ `bun update --latest` across the board — per-package `--latest`/`package.json` edit for exact pins. - ❌ Treating `bun audit` as pass/fail — every advisory needs a cited verdict; a high/critical audit is blocking-with-triage. +- ❌ `bun audit fix`. It treats exact pins as `^version` and rewrites `package.json`. +- ❌ Isolated linker. Do not set `linker = "isolated"`. Existing lockfiles stay hoisted. +- ❌ bunfig `[test]` for isolate. `--isolate` is CLI-only. `--parallel` implies isolate. - ❌ Skipping `bun run build` — bundler/codegen breakage beats type breakage for dep upgrades. ## Reference diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f22761c7..88f097d5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ Codemap is in **bootstrap / extraction** phase. Before large PRs, please open an issue so we can align on: - **Core vs adapter** — core should stay small; language-specific logic belongs in **adapters** (see [docs/roadmap.md](../docs/roadmap.md)). -- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**); SQLite is **`better-sqlite3`** on Node and **`bun:sqlite`** on Bun ([docs/architecture.md](../docs/architecture.md), [docs/packaging.md § Node vs Bun](../docs/packaging.md#node-vs-bun)). +- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.31` (`package.json` **engines**); SQLite is **`better-sqlite3`** on Node and **`bun:sqlite`** on Bun ([docs/architecture.md](../docs/architecture.md), [docs/packaging.md § Node vs Bun](../docs/packaging.md#node-vs-bun)). Maintainers use **Bun 1.4.0** (`packageManager`). ## Dev workflow diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b32a7e07..2052b300 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -12,7 +12,7 @@ runs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.4.0 - name: Install packages shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5644d7f4..84b271f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,6 +242,24 @@ jobs: - name: Audit docs site run: bun run docs:audit + dedupe: + # Fails when bun.lock still has duplicate versions (`bun dedupe --check`). + name: 🧹 Dedupe + needs: skip-ci + if: needs['skip-ci'].outputs.skip != 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: bun dedupe --check + run: bun run dedupe:check + audit: # `bun audit` exits 0 regardless of severity — the scan below blocks on high/critical. # Transitive-dep CVE visibility; advisory-API outage doesn't block. @@ -314,6 +332,7 @@ jobs: check-pack, docs, audit, + dedupe, benchmark, ] if: always() @@ -330,6 +349,7 @@ jobs: needs['check-pack'].result != 'success' || needs.docs.result != 'success' || needs.audit.result != 'success' || + needs.dedupe.result != 'success' || needs.benchmark.result != 'success' ) run: exit 1 diff --git a/bun.lock b/bun.lock index 3c7cf869..5174fd7b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.1", "@types/better-sqlite3": "7.6.13", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "husky": "9.1.7", @@ -51,6 +51,7 @@ "nanoid": "3.3.18", "path-to-regexp": "6.3.0", "qs": ">=6.15.2", + "tar": "7.5.22", }, "packages": { "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "4.0.5", "@ai-sdk/provider-utils": "5.0.20", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-poNySlk+zSe04M4v0wgJKt+mzY6Vk6abcQid58YZnFUBJYo6VwfsFUaW7oINoR1vYYB7Ne6eqY1doSxYKt0KDg=="], @@ -729,7 +730,7 @@ "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -1015,7 +1016,7 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -2045,7 +2046,7 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -2153,7 +2154,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.20", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], @@ -2309,15 +2310,15 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.2", "", { "dependencies": { "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg=="], + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], @@ -2431,8 +2432,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/better-sqlite3/@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], - "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -2453,8 +2452,6 @@ "astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], - "astro/obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], - "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "blume/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -2463,8 +2460,6 @@ "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "bun-types/@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], - "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -2511,8 +2506,6 @@ "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], "shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg=="], @@ -2523,8 +2516,6 @@ "shiki/@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="], - "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -2539,16 +2530,10 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "unrun/rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], - "vite/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], - "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - "yaml-language-server/prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], @@ -2585,8 +2570,6 @@ "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], @@ -2613,40 +2596,6 @@ "svgo/css-select/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], - - "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], - - "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], - - "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], - - "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], - - "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], - - "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], - - "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], - - "unrun/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], - - "unrun/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], - - "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], - - "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], - - "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], - - "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], - - "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], - - "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], - "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], @@ -2701,20 +2650,12 @@ "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], - "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "node-html-parser/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "svgo/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], - "unrun/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "unrun/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], diff --git a/lint-staged.config.js b/lint-staged.config.js index 4e147d1a..653f13ab 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -60,7 +60,7 @@ function relatedTests(filenames) { if (tests.length === 0) { return "true"; } - return `bun test ${tests.join(" ")}`; + return `bun test --isolate ${tests.join(" ")}`; } /** @type {import('lint-staged').Configuration} */ @@ -70,7 +70,8 @@ export default { // (oxfmt collapses Blume `:::` fences). "*.{css,json,md,mdc,mdx,html,yaml,yml}": "bun run format:check", "*.{ts,tsx}": [typecheckStagedFiles, relatedTests], - "*.test.ts": "bun test", - "*.test.tsx": "bun test", - "scripts/**/*.test.mjs": "bun test", + "*.test.ts": "bun test --isolate", + "*.test.tsx": "bun test --isolate", + "scripts/**/*.test.mjs": "bun test --isolate", + "scripts/**/*.test.ts": "bun test --isolate", }; diff --git a/package.json b/package.json index eea5351e..267b4378 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,13 @@ "benchmark:query": "bun scripts/benchmark-query-output.ts", "build": "tsdown", "changeset": "changeset", - "check": "bun run build && bun run --parallel format:check lint:ci test test:scripts typecheck && bun run test:golden && bun run test:agent-eval", + "check": "bun run build && bun run --parallel format:check lint:ci test test:scripts typecheck dedupe:check && bun run test:golden && bun run test:agent-eval", "check-updates": "bun update -i --latest", "check:pack": "attw --pack . --profile esm-only && publint", "check:perf-baseline": "bun scripts/check-perf-baseline.ts", "check:perf-baseline:update": "bun scripts/check-perf-baseline.ts --update", "clean": "git clean -xdf -e .env -e docs/research/scratch", + "dedupe:check": "bun dedupe --check", "dev": "bun src/index.ts", "docs:api": "bun apps/docs/scripts/rewrite-api-links.ts --clean && typedoc && bun apps/docs/scripts/rewrite-api-links.ts", "docs:audit": "bun run --filter '@stainless-code/codemap-docs' audit", @@ -82,13 +83,13 @@ "prepublishOnly": "bun run check && bun run check:pack", "qa:external": "bun scripts/qa-external-repo.ts", "release": "changeset publish", - "test": "bun test ./src", - "test:agent-eval": "bun test scripts/agent-eval", + "test": "bun test --isolate ./src", + "test:agent-eval": "bun test --isolate scripts/agent-eval", "test:ci": "bun run test:coverage", "test:coverage": "bun test --coverage --coverage-threshold=0.75 ./src", "test:golden": "bun scripts/query-golden.ts", "test:golden:external": "bun scripts/query-golden.ts --corpus external", - "test:scripts": "bash -c 'files=$(find scripts -name \"*.test.mjs\"); if [ -z \"$files\" ]; then echo \"no scripts test files found\" >&2; exit 1; fi; exec bun test $files'", + "test:scripts": "bash -c 'files=$(find scripts -name \"*.test.mjs\"); if [ -z \"$files\" ]; then echo \"no scripts test files found\" >&2; exit 1; fi; exec bun test --isolate $files scripts/upgrade-packages'", "typecheck": "tsgo --noEmit", "upgrade-packages:evidence": "bun run scripts/upgrade-packages/evidence.ts", "version": "changeset version && bun run format CHANGELOG.md" @@ -110,7 +111,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.1", "@types/better-sqlite3": "7.6.13", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "husky": "9.1.7", @@ -131,12 +132,13 @@ "js-yaml": "4.3.1", "nanoid": "3.3.18", "path-to-regexp": "6.3.0", - "qs": ">=6.15.2" + "qs": ">=6.15.2", + "tar": "7.5.22" }, "engines": { "bun": ">=1.0.31", "node": "^20.19.0 || >=22.12.0" }, - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.0", "contributing": ".github/CONTRIBUTING.md" } diff --git a/scripts/upgrade-packages/evidence.test.ts b/scripts/upgrade-packages/evidence.test.ts new file mode 100644 index 00000000..468c2f8c --- /dev/null +++ b/scripts/upgrade-packages/evidence.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; + +import { ghsaVerdict, parseOutdatedTable, semverInRange } from "./evidence"; + +describe("semverInRange", () => { + it("matches GHSA AND/OR ranges", () => { + expect(semverInRange("8.0.0", ">= 7.0.0 < 9.0.6")).toBe(true); + expect(semverInRange("9.0.6", ">= 7.0.0 < 9.0.6")).toBe(false); + expect(semverInRange("3.3.18", ">=4.0.0 || >=3.0.0 <3.4.0")).toBe(true); + expect(semverInRange("3.4.0", ">=4.0.0 || >=3.0.0 <3.4.0")).toBe(false); + }); + + it("is false for a missing range", () => { + expect(semverInRange("1.0.0", null)).toBe(false); + }); + + it("does not treat an unparseable OR group as a match", () => { + expect(semverInRange("1.0.0", "not-a-range || also-bad")).toBe(false); + expect(semverInRange("1.0.0", "not-a-range < 2.0.0")).toBe(false); + }); +}); + +describe("ghsaVerdict", () => { + it("picks priority-bump when the target already ships the fix", () => { + expect(ghsaVerdict(true, "4.3.1", "4.3.1")).toBe("priority-bump"); + expect(ghsaVerdict(true, "4.3.1", "4.4.0")).toBe("priority-bump"); + }); + + it("needs a higher target when the fix is past latest", () => { + expect(ghsaVerdict(true, "5.0.0", "4.3.1")).toBe("needs-higher-target"); + }); + + it("clears when the installed version is outside the range", () => { + expect(ghsaVerdict(false, "4.3.1", "4.3.1")).toBe("cleared-at-current"); + }); + + it("is unpatched when in range with no fix", () => { + expect(ghsaVerdict(true, null, "4.3.1")).toBe("unpatched"); + }); +}); + +describe("parseOutdatedTable", () => { + it("parses bun outdated rows and strips (dev)", () => { + const stdout = [ + "| Package | Current | Update | Latest |", + "| --- | --- | --- | --- |", + "| immer | 10.0.0 | 10.1.0 | 10.1.1 |", + "| zod (dev) | 3.23.0 | 3.24.0 | 3.24.2 |", + "| already | 1.0.0 | 1.0.0 | 1.0.0 |", + ].join("\n"); + expect(parseOutdatedTable(stdout)).toEqual([ + { + pkg: "immer", + current: "10.0.0", + latest: "10.1.1", + bumpClass: "minor", + coupledWith: [], + dev: false, + }, + { + pkg: "zod", + current: "3.23.0", + latest: "3.24.2", + bumpClass: "minor", + coupledWith: [], + dev: true, + }, + ]); + }); + + it("keeps the same package at two current versions", () => { + const stdout = [ + "| Package | Current | Update | Latest | Workspace |", + "| --- | --- | --- | --- | --- |", + "| react | 18.3.1 | 19.0.0 | 19.2.0 | @stainless-code/codemap |", + "| react | 19.1.0 | 19.2.0 | 19.2.0 | @stainless-code/codemap-docs |", + ].join("\n"); + const rows = parseOutdatedTable(stdout); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.current)).toEqual(["18.3.1", "19.1.0"]); + }); + + it("parses workspace-filter rows and strips (peer)/(optional)", () => { + const stdout = [ + "| Package | Current | Update | Latest | Workspace |", + "| --- | --- | --- | --- | --- |", + "| react (peer) | 19.2.7 | 19.2.8 | 19.2.8 | @stainless-code/react-layers |", + "| alpinejs (optional) | 3.15.12 | 3.15.12 | 3.16.0 | @stainless-code/persist |", + ].join("\n"); + expect(parseOutdatedTable(stdout)).toEqual([ + { + pkg: "react", + current: "19.2.7", + latest: "19.2.8", + bumpClass: "patch", + coupledWith: [], + dev: false, + }, + { + pkg: "alpinejs", + current: "3.15.12", + latest: "3.16.0", + bumpClass: "minor", + coupledWith: [], + dev: false, + }, + ]); + }); +}); diff --git a/scripts/upgrade-packages/evidence.ts b/scripts/upgrade-packages/evidence.ts index e81bb35c..f9b70914 100644 --- a/scripts/upgrade-packages/evidence.ts +++ b/scripts/upgrade-packages/evidence.ts @@ -1,22 +1,30 @@ /** - * upgrade-packages-evidence.ts + * evidence.ts * * Deterministic evidence gatherer for the `upgrade-packages` skill. * Emits a JSON artifact the AI agent reads and judges — the agent never - * touches the registry, GitHub, or GHSA directly. Citations (diffUrl, - * changelogUrl, advisory URL) are artifact fields, so model priors can't - * sneak in. + * touches the registry, GitHub, or GHSA directly. Citations (tarball notes + * and kept patches, changelogUrl, advisory URL) are artifact fields, so + * model priors can't sneak in. * * Usage: - * bun run scripts/upgrade-packages-evidence.ts [--out ] [--only ] - * bun run scripts/upgrade-packages-evidence.ts --only immer # tracer bullet + * bun run upgrade-packages:evidence [--out ] [--only ] + * bun run upgrade-packages:evidence --only immer # tracer bullet * - * Artifact schema: see `Evidence` type below. + * Defaults: --out scripts/upgrade-packages/artifact.json; cache colocated + * at scripts/upgrade-packages/.cache/ (GHSA 1h, bun pm diff 7d). Artifact + * schema: see `Evidence` type below. */ -// `export {}` keeps this file a Module (not a global Script) so its `main` -// doesn't collide with other script-mode files' `main` under tsgo. -export {}; +import { readdirSync } from "node:fs"; + +import type { BunPmDiffJson, Delta } from "./tarball-delta"; +import { + buildDelta, + failedDelta, + npmVersionUrl, + selectPatchPaths, +} from "./tarball-delta"; // ──────────────────────────────────────────────────────────────────────────── // Types — the artifact contract the AI agent reads @@ -30,6 +38,7 @@ interface OutdatedPkg { latest: string; bumpClass: BumpClass; coupledWith: string[]; // peer/dep that forces a higher band (filled naively here) + dev: boolean; } interface AdvisoryVuln { @@ -49,21 +58,6 @@ interface AdvisoryVuln { error?: string; } -interface Delta { - version: string; - date: string | null; - breaking: string[]; - deprecations: string[]; - features: string[]; - security: string[]; - peerEngine: string[]; - releaseNotes: string | null; // raw body, truncated - diffUrl: string | null; // github.com///compare/... - changelogUrl: string | null; - source: "github-release" | "none"; - error: string | null; -} - interface Usage { importedSymbols: string[]; typeOnlySymbols: string[]; @@ -159,16 +153,21 @@ async function ghGate() { const SCRIPT_DIR = import.meta.dir; const CACHE_DIR = `${SCRIPT_DIR}/.cache`; const DEFAULT_OUT = `${SCRIPT_DIR}/artifact.json`; -const CACHE_MAX_AGE_MS = 1000 * 60 * 60; // 1 hour +const CACHE_MAX_AGE_MS = 1000 * 60 * 60; // 1 hour — GHSA can change +const PMDIFF_CACHE_MS = 1000 * 60 * 60 * 24 * 7; // published tarball pair is immutable +const DIFF_CONCURRENCY = 4; function cachePath(key: string): string { return `${CACHE_DIR}/${key.replace(/[^a-z0-9._-]/gi, "_")}.json`; } -async function readCache(key: string): Promise { +async function readCache( + key: string, + maxAgeMs = CACHE_MAX_AGE_MS, +): Promise { const f = Bun.file(cachePath(key)); if (!(await f.exists())) return null; - if (Date.now() - f.lastModified > CACHE_MAX_AGE_MS) return null; + if (Date.now() - f.lastModified > maxAgeMs) return null; return await f.text(); } @@ -229,7 +228,7 @@ function bumpClass(current: string, latest: string): BumpClass { return lb !== cb ? "minor" : "patch"; } -function semverInRange(version: string, range: string | null): boolean { +export function semverInRange(version: string, range: string | null): boolean { if (!range) return false; // Naive but covers the GHSA `vulnerable_version_range` shapes we see: // comma- or space-separated comparators (AND), and `||` groups (OR). @@ -243,9 +242,14 @@ function semverInRange(version: string, range: string | null): boolean { .filter(Boolean); if (clauses.length === 0) continue; let groupOk = true; + let parsed = 0; for (const clause of clauses) { const m = clause.match(/^(>=|<=|>|<|=)?\s*(\d[^-+]*)/); - if (!m) continue; + if (!m) { + groupOk = false; + break; + } + parsed += 1; const [, op, ver] = m; const c = cmpVer(version, ver); if (op === ">=" && !(c >= 0)) groupOk = false; @@ -254,11 +258,26 @@ function semverInRange(version: string, range: string | null): boolean { if (op === "<" && !(c < 0)) groupOk = false; if ((!op || op === "=") && c !== 0) groupOk = false; } - if (groupOk) return true; + if (parsed > 0 && groupOk) return true; } return false; } +export function ghsaVerdict( + inRange: boolean, + fixedIn: string | null, + targetVer: string, +): AdvisoryVuln["verdict"] { + if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) <= 0) { + return "priority-bump"; + } + if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) > 0) { + return "needs-higher-target"; + } + if (!inRange) return "cleared-at-current"; + return "unpatched"; +} + // ──────────────────────────────────────────────────────────────────────────── // Gatherers // ──────────────────────────────────────────────────────────────────────────── @@ -274,20 +293,50 @@ const HIGH_RISK = [ "tsdown", ]; +function workspaceManifests(): string[] { + const manifests = ["package.json"]; + for (const dir of ["packages", "apps"]) { + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + manifests.push(`${dir}/${entry.name}/package.json`); + } + } + } catch { + // Directory may not exist in this repo. + } + } + return manifests; +} + async function parsePackageJson(): Promise { - const pkg = JSON.parse(await Bun.file("package.json").text()); const direct: Evidence["inventory"]["direct"] = []; const classify = (v: string): "exact" | "caret" | "tilde" => v.startsWith("^") ? "caret" : v.startsWith("~") ? "tilde" : "exact"; - for (const [name, version] of Object.entries( - pkg.dependencies ?? {}, - )) { - direct.push({ name, version, range: classify(version), dev: false }); - } - for (const [name, version] of Object.entries( - pkg.devDependencies ?? {}, - )) { - direct.push({ name, version, range: classify(version), dev: true }); + const seen = new Set(); + const add = (name: string, version: string, dev: boolean) => { + if (version.startsWith("workspace:")) return; + const key = `${name}@${version}:${dev}`; + if (seen.has(key)) return; + seen.add(key); + direct.push({ name, version, range: classify(version), dev }); + }; + for (const manifest of workspaceManifests()) { + const raw = await Bun.file(manifest) + .text() + .catch(() => ""); + if (!raw) continue; + const pkg = JSON.parse(raw); + for (const [name, version] of Object.entries( + pkg.dependencies ?? {}, + )) { + add(name, version, false); + } + for (const [name, version] of Object.entries( + pkg.devDependencies ?? {}, + )) { + add(name, version, true); + } } // Transitive duplicates: parse bun.lock for packages resolved at multiple versions // where one version is a direct dep (the signal the skill cares about). @@ -320,29 +369,55 @@ async function parsePackageJson(): Promise { return { direct, transitiveDuplicates }; } -async function parseBunOutdated(): Promise { - const { stdout } = await runSoft(["bun", "outdated"]); +export function parseOutdatedTable(stdout: string): OutdatedPkg[] { const out: OutdatedPkg[] = []; - // Table rows: | | | | | + const seen = new Set(); + // Root table: | Package | Current | Update | Latest | + // Workspace table (`bun outdated --filter '*'`): extra trailing Workspace column. + // Display suffixes: " (dev)" / " (peer)" / " (optional)". for (const line of stdout.split("\n")) { - const m = line.match( - /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|$/, - ); - if (!m) continue; - const [, pkg, current, , latest] = m.map((s) => s.trim()); - if (pkg === "Package" || pkg.startsWith("---")) continue; - if (current === latest) continue; + if (!line.trim().startsWith("|")) continue; + const cells = line + .split("|") + .slice(1, -1) + .map((s) => s.trim()); + if (cells.length < 4) continue; + const [pkgRaw, current, , latest] = cells; + if (!pkgRaw || pkgRaw === "Package" || pkgRaw.startsWith("---")) continue; + if (!current || !latest || current === latest) continue; + const isDev = /\s*\(dev\)\s*$/.test(pkgRaw); + const pkg = pkgRaw.replace(/\s*\((?:dev|peer|optional)\)\s*$/, ""); + const key = `${pkg}@${current}->${latest}`; + if (seen.has(key)) continue; + seen.add(key); out.push({ pkg, current, latest, bumpClass: bumpClass(current, latest), coupledWith: [], + dev: isDev, }); } return out; } +async function parseBunOutdated(): Promise { + // `--filter '*'` covers every workspace package (root-only `bun outdated` + // misses per-package deps). Adds a trailing Workspace column. + const { ok, stdout, code } = await runSoft([ + "bun", + "outdated", + "--filter", + "*", + ]); + const out = parseOutdatedTable(stdout); + if (!ok && out.length === 0) { + throw new Error(`bun outdated exited ${code} with no parseable rows`); + } + return out; +} + async function runBunAudit(): Promise { const { stdout } = await runSoft(["bun", "audit", "--json"]); const body = stripBunHeader(stdout); @@ -356,6 +431,97 @@ async function runBunAudit(): Promise { } } +async function fetchGhsaList(pkg: string): Promise { + return cachedParsed( + `ghsa:${pkg}`, + CACHE_MAX_AGE_MS, + () => + run( + [ + "gh", + "api", + "-X", + "GET", + "/advisories", + "-f", + "ecosystem=npm", + "-f", + `affects=${pkg}`, + ], + { retries: 2 }, + ), + (raw) => { + const list = JSON.parse(raw); + if (!Array.isArray(list)) { + throw new Error( + `non-array response from gh api advisories: ${String(list).slice(0, 120)}`, + ); + } + return list; + }, + ); +} + +function advisoryFromGhsa( + a: { + ghsa_id: string; + cve_id?: string | null; + severity?: string; + vulnerabilities?: { + package?: { name?: string }; + vulnerable_version_range?: string | null; + first_patched_version?: { identifier?: string } | string | null; + }[]; + }, + pkg: string, + installed: Map, + target: Map, +): AdvisoryVuln { + const vuln = + a.vulnerabilities?.find((v) => v.package?.name === pkg) ?? + a.vulnerabilities?.[0] ?? + {}; + const range = vuln.vulnerable_version_range ?? null; + const patched = vuln.first_patched_version; + const fixedIn = + typeof patched === "string" ? patched : (patched?.identifier ?? null); + const installedVer = installed.get(pkg) ?? ""; + const targetVer = target.get(pkg) ?? ""; + const inRange = installedVer ? semverInRange(installedVer, range) : false; + return { + id: a.ghsa_id, + cveId: a.cve_id ?? null, + severity: a.severity ?? "unknown", + vulnerableRange: range, + fixedIn, + installedInRange: inRange, + verdict: ghsaVerdict(inRange, fixedIn, targetVer), + url: `https://github.com/advisories/${a.ghsa_id}`, + }; +} + +function ghsaCheckFailed( + pkg: string, + e: unknown, +): Evidence["audit"]["ghsa"][number] { + return { + pkg, + advisories: [ + { + id: "error", + cveId: null, + severity: "unknown", + vulnerableRange: null, + fixedIn: null, + installedInRange: false, + verdict: "check-failed", + url: "", + error: e instanceof Error ? e.message : String(e), + }, + ], + }; +} + async function ghsaSpotCheck( pkgs: string[], installed: Map, @@ -364,300 +530,119 @@ async function ghsaSpotCheck( const out: Evidence["audit"]["ghsa"] = []; for (const pkg of pkgs) { try { - const cacheKey = `ghsa:${pkg}`; - const cached = await readCache(cacheKey); - const raw = - cached ?? - (await run( - [ - "gh", - "api", - "-X", - "GET", - "/advisories", - "-f", - "ecosystem=npm", - "-f", - `affects=${pkg}`, - ], - { retries: 2 }, - )); - const list = JSON.parse(raw); - if (!Array.isArray(list)) { - throw new Error( - `non-array response from gh api advisories: ${String(list).slice(0, 120)}`, - ); - } - if (!cached) await writeCache(cacheKey, JSON.stringify(list)); - const advisories: AdvisoryVuln[] = []; - for (const a of list) { - const vuln = - a.vulnerabilities?.find((v: any) => v.package?.name === pkg) ?? - a.vulnerabilities?.[0] ?? - {}; - const range: string | null = vuln.vulnerable_version_range ?? null; - const fixedIn: string | null = - vuln.first_patched_version?.identifier ?? - vuln.first_patched_version ?? - null; - const installedVer = installed.get(pkg) ?? ""; - const targetVer = target.get(pkg) ?? ""; - const inRange = installedVer - ? semverInRange(installedVer, range) - : false; - let verdict: AdvisoryVuln["verdict"] = "unpatched"; - if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) <= 0) - verdict = "priority-bump"; - else if ( - inRange && - fixedIn && - targetVer && - cmpVer(fixedIn, targetVer) > 0 - ) - verdict = "needs-higher-target"; - else if (!inRange) verdict = "cleared-at-current"; - advisories.push({ - id: a.ghsa_id, - cveId: a.cve_id ?? null, - severity: a.severity ?? "unknown", - vulnerableRange: range, - fixedIn, - installedInRange: inRange, - verdict, - url: `https://github.com/advisories/${a.ghsa_id}`, - }); - } - out.push({ pkg, advisories }); - } catch (e) { + const list = await fetchGhsaList(pkg); out.push({ pkg, - advisories: [ - { - id: "error", - cveId: null, - severity: "unknown", - vulnerableRange: null, - fixedIn: null, - installedInRange: false, - verdict: "check-failed", - url: "", - error: e instanceof Error ? e.message : String(e), - }, - ], + advisories: list.map((a) => + advisoryFromGhsa( + a as Parameters[0], + pkg, + installed, + target, + ), + ), }); + } catch (e) { + out.push(ghsaCheckFailed(pkg, e)); } } return out; } -async function getRepoSlug( - pkg: string, -): Promise<{ owner: string; repo: string } | null> { - try { - const raw = stripBunHeader( - await run(["bun", "pm", "view", pkg, "repository", "--json"], { - retries: 2, - }), - ); - const data = JSON.parse(raw); - const url: string = data.url ?? ""; - // Preserve dots in repo names (e.g. mozilla/pdf.js); strip a trailing .git. - const m = url.match( - /github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/, - ); - return m ? { owner: m[1], repo: m[2] } : null; - } catch { - return null; +async function cachedParsed( + key: string, + maxAgeMs: number, + fetchText: () => Promise, + parse: (raw: string) => T, +): Promise { + const cached = await readCache(key, maxAgeMs); + if (cached) return parse(cached); + const raw = await fetchText(); + const value = parse(raw); + await writeCache(key, raw); + return value; +} + +function parseDiffJson(raw: string, label: string): BunPmDiffJson { + const body = stripBunHeader(raw); + const data: unknown = JSON.parse(body); + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error(`${label}: expected a JSON object`); } -} - -async function getVersions(pkg: string): Promise { - try { - const raw = stripBunHeader( - await run(["bun", "pm", "view", pkg, "versions", "--json"], { - retries: 2, - }), - ); - const list = JSON.parse(raw); - return Array.isArray(list) ? list : []; - } catch { - return []; + const obj = data as Record; + if ("files" in obj && !Array.isArray(obj.files)) { + throw new Error(`${label}: files must be an array`); + } + if ("notes" in obj && !Array.isArray(obj.notes)) { + throw new Error(`${label}: notes must be an array`); } + return data as BunPmDiffJson; } -/** Walk the changelog between current and target. Naive categorization of release body. */ +/** One current→target tarball span via `bun pm diff`. Stat first, then patches for keep-paths. */ async function gatherDeltas( pkg: string, current: string, target: string, + importedSymbols: string[], ): Promise { - if (isPrerelease(target) || isPrerelease(current)) { - return [ - { - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: - "prerelease/moving-target build — no per-version changelog; gate on same-major-line only", - }, - ]; - } - const slug = await getRepoSlug(pkg); - if (!slug) { + const changelogUrl = npmVersionUrl(pkg, target); + try { + const spec = `${pkg}@${current}`; + const range = `${pkg}@${current}..${target}`; + const stat = await cachedParsed( + `pmdiff-stat:${range}`, + PMDIFF_CACHE_MS, + () => + run(["bun", "pm", "diff", spec, target, "--json", "--stat"], { + retries: 1, + }), + (raw) => parseDiffJson(raw, `bun pm diff --stat ${range}`), + ); + const patchPaths = selectPatchPaths(stat.files ?? [], importedSymbols); + let patches: BunPmDiffJson | null = null; + if (patchPaths.length > 0) { + const patchKey = `pmdiff-patch:${range}:${Bun.hash(patchPaths.join("\0")).toString(16)}`; + patches = await cachedParsed( + patchKey, + PMDIFF_CACHE_MS, + () => + run(["bun", "pm", "diff", spec, target, "--json", ...patchPaths], { + retries: 1, + }), + (raw) => parseDiffJson(raw, `bun pm diff patches ${range}`), + ); + } + return [buildDelta({ target, changelogUrl, stat, patches })]; + } catch (e) { return [ - { - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: "no github repository found", - }, - ]; - } - const { owner, repo } = slug; - const allVersions = await getVersions(pkg); - const inRange = allVersions - .filter( - (v) => - !isPrerelease(v) && cmpVer(v, current) > 0 && cmpVer(v, target) <= 0, - ) - .sort((a, b) => cmpVer(a, b)); - - // Fetch the repo's release list once and map version → actual tag name. - // Handles v, , @scope/pkg@, release-, etc. — whatever the repo uses. - const releases = await fetchReleaseMap(owner, repo); - const tagOf = (v: string): string | null => releases.get(v)?.tagName ?? null; - - const deltas: Delta[] = []; - let prevTag = tagOf(current) ?? `v${current}`; - for (const v of inRange) { - const rel = tagOf(v) ? releases.get(v) : null; - const tag = rel?.tagName ?? null; - const diffUrl = tag - ? `https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}` - : null; - const body = rel?.body ?? null; - const date = rel?.publishedAt ?? null; - const source: Delta["source"] = rel ? "github-release" : "none"; - const error = rel - ? null - : `no github release for ${pkg}@${v} (${releases.size} releases scanned — likely a gh secondary rate-limit or monorepo squashed release; deep-dive via changelogUrl)`; - // Always provide a deep-dive link: the specific tag release page when known, - // else the repo's releases page (so the model can browse tags when the script couldn't). - const changelogUrl = tag - ? `https://github.com/${owner}/${repo}/releases/tag/${tag}` - : `https://github.com/${owner}/${repo}/releases`; - deltas.push({ - version: v, - date, - breaking: extractLines(body, /breaking|breaking change/i), - deprecations: extractLines(body, /deprecat|removed export/i), - features: extractLines(body, /^feat|feature|^add|^new/i), - security: extractLines( - body, - /security|cve|prototype pollution|vulnerabilit/i, + failedDelta( + target, + changelogUrl, + e instanceof Error ? e.message : String(e), ), - peerEngine: extractLines( - body, - /peer dep|engine|requires (node|bun|react)/i, - ), - releaseNotes: body, - diffUrl, - changelogUrl, - source, - error, - }); - if (tag) prevTag = tag; - } - if (deltas.length === 0) { - deltas.push({ - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: "no versions found in range", - }); + ]; } - return deltas; } -/** Fetch a repo's releases (with bodies) in one paginated call. Maps version → release. */ -async function fetchReleaseMap( - owner: string, - repo: string, -): Promise< - Map< - string, - { tagName: string; publishedAt: string | null; body: string | null } - > -> { - const map = new Map< - string, - { tagName: string; publishedAt: string | null; body: string | null } - >(); - const cacheKey = `releases:${owner}/${repo}`; - try { - const cached = await readCache(cacheKey); - const raw = - cached ?? - (await run([ - "gh", - "api", - `repos/${owner}/${repo}/releases?per_page=100`, - ])); - const list = JSON.parse(raw); - if (!Array.isArray(list)) { - // gh returns a JSON object (e.g. {"message":"secondary rate limit"}) on rate-limit — not an array. - throw new Error( - `non-array response from gh api releases: ${String(raw).slice(0, 120)}`, - ); - } - if (!cached) await writeCache(cacheKey, JSON.stringify(list)); - for (const r of list) { - const m = (r.tag_name as string).match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/); - if (m) - map.set(m[1], { - tagName: r.tag_name, - publishedAt: r.published_at ?? null, - body: r.body ? String(r.body).slice(0, 1200) : null, - }); +async function mapPool( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = []; + let next = 0; + async function worker() { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i]!); } - } catch { - // repo has no releases or gh failed (secondary rate-limit / not found) — empty map; caller records error per version. } - return map; -} - -function extractLines(body: string | null, re: RegExp): string[] { - if (!body) return []; - return body - .split("\n") - .map((l) => l.trim()) - .filter((l) => re.test(l) && l.length > 0) - .slice(0, 6) - .map((l) => l.replace(/^[#*\-\s]+/, "").slice(0, 160)); + const n = Math.min(concurrency, items.length); + if (n === 0) return out; + await Promise.all(Array.from({ length: n }, () => worker())); + return out; } // ──────────────────────────────────────────────────────────────────────────── @@ -874,28 +859,44 @@ async function main() { let outdated = await parseBunOutdated(); if (onlyPkg) outdated = outdated.filter((o) => o.pkg === onlyPkg); const target = new Map(outdated.map((o) => [o.pkg, o.latest])); + // Manifest inventory is a range floor; `bun outdated` reports the resolved version. + for (const o of outdated) installed.set(o.pkg, o.current); - console.error("→ bun audit"); - const bunAudit = await runBunAudit(); - - console.error("→ ghsa spot-check"); + console.error("→ bun audit + ghsa"); const ghsaPkgs = (onlyPkg ? [onlyPkg] : HIGH_RISK).filter( (p) => installed.has(p) || target.has(p), ); - const ghsa = await ghsaSpotCheck(ghsaPkgs, installed, target); - - console.error("→ deltas"); - const deltas: Record = {}; - for (const o of outdated) { - console.error(` ${o.pkg} ${o.current} → ${o.latest}`); - deltas[o.pkg] = await gatherDeltas(o.pkg, o.current, o.latest); - } + const [bunAudit, ghsa] = await Promise.all([ + runBunAudit(), + ghsaSpotCheck(ghsaPkgs, installed, target), + ]); console.error("→ usage (batched codemap)"); const usageMap = await gatherAllUsageWithFallback(outdated.map((o) => o.pkg)); const usage: Record = {}; - for (const o of outdated) - usage[o.pkg] = usageMap.get(o.pkg) ?? (await grepUsage(o.pkg)); + for (const o of outdated) { + const u = usageMap.get(o.pkg); + if (!u) throw new Error(`usage missing for ${o.pkg}`); + usage[o.pkg] = u; + } + + console.error(`→ deltas (bun pm diff, ${DIFF_CONCURRENCY} at a time)`); + const deltaEntries = await mapPool(outdated, DIFF_CONCURRENCY, async (o) => { + console.error(` ${o.pkg} ${o.current} → ${o.latest}`); + const u = usage[o.pkg]; + const symbols = [ + ...(u?.importedSymbols ?? []), + ...(u?.typeOnlySymbols ?? []), + ]; + return [ + o.pkg, + await gatherDeltas(o.pkg, o.current, o.latest, symbols), + ] as const; + }); + const deltas: Record = {}; + for (const [pkg, list] of deltaEntries) { + deltas[pkg] = [...(deltas[pkg] ?? []), ...list]; + } const evidence: Evidence = { generatedAt: new Date().toISOString(), @@ -915,7 +916,9 @@ async function main() { } } -main().catch((e) => { - console.error("fatal:", e); - process.exit(1); -}); +if (import.meta.main) { + main().catch((e) => { + console.error("fatal:", e); + process.exit(1); + }); +} diff --git a/scripts/upgrade-packages/tarball-delta.test.ts b/scripts/upgrade-packages/tarball-delta.test.ts new file mode 100644 index 00000000..09f436df --- /dev/null +++ b/scripts/upgrade-packages/tarball-delta.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "bun:test"; + +import type { BunPmDiffFile } from "./tarball-delta"; +import { + FILE_LIST_CAP, + PATCH_MAX_CHARS, + PATCH_PATH_CAP, + addedPatchLines, + buildDelta, + classifyChangelogLines, + classifyNote, + extractDate, + failedDelta, + isKeepPath, + isSkipFile, + npmVersionUrl, + selectPatchPaths, + shouldKeepPatch, + slimFiles, +} from "./tarball-delta"; + +const patch = ( + path: string, + extra: Partial = {}, +): BunPmDiffFile => ({ + path, + status: "modified", + linesAdded: extra.linesAdded ?? 4, + linesRemoved: extra.linesRemoved ?? 1, + ...extra, +}); + +describe("isKeepPath / isSkipFile", () => { + it("keeps changelog, package.json, readme, and declaration files", () => { + expect(isKeepPath("CHANGELOG.md")).toBe(true); + expect(isKeepPath("package.json")).toBe(true); + expect(isKeepPath("README.md")).toBe(true); + expect(isKeepPath("dist/index.d.ts")).toBe(true); + expect(isKeepPath("dist/index.d.mts")).toBe(true); + expect(isKeepPath("src/index.js")).toBe(false); + }); + + it("skips source maps", () => { + expect(isSkipFile({ path: "dist/a.js.map" })).toBe(true); + expect(isSkipFile({ path: "dist/a.js", sourceMap: true })).toBe(true); + expect(isSkipFile({ path: "dist/a.js" })).toBe(false); + }); +}); + +describe("selectPatchPaths", () => { + it("puts named keep files first, then dts, then highest churn", () => { + const paths = selectPatchPaths([ + patch("dist/huge.js", { linesAdded: 200, linesRemoved: 50 }), + patch("package.json", { linesAdded: 2, linesRemoved: 2 }), + patch("dist/index.d.ts", { linesAdded: 10, linesRemoved: 1 }), + patch("CHANGELOG.md", { linesAdded: 8, linesRemoved: 0 }), + patch("dist/a.js.map", { linesAdded: 999, sourceMap: true }), + ]); + expect(paths[0]).toBe("package.json"); + expect(paths[1]).toBe("CHANGELOG.md"); + expect(paths[2]).toBe("dist/index.d.ts"); + expect(paths[3]).toBe("dist/huge.js"); + expect(paths).not.toContain("dist/a.js.map"); + expect( + selectPatchPaths([ + patch("dist/huge.production.mjs", { linesAdded: 400 }), + patch("src/mapset.ts", { linesAdded: 12 }), + ]), + ).toEqual(["src/mapset.ts"]); + }); + + it("boosts extras whose path mentions an imported symbol", () => { + expect( + selectPatchPaths( + [ + patch("src/unrelated.ts", { linesAdded: 80 }), + patch("src/produce.ts", { linesAdded: 4 }), + ], + ["produce"], + ), + ).toEqual(["src/produce.ts", "src/unrelated.ts"]); + }); + + it("does not let formatting-only dts eat PATCH_PATH_CAP", () => { + const files = [ + ...Array.from({ length: PATCH_PATH_CAP }, (_, i) => + patch(`dist/n${i}.d.ts`, { linesAdded: 1, formattingOnly: true }), + ), + patch("src/useLottie.ts", { linesAdded: 4 }), + ]; + expect(selectPatchPaths(files, ["useLottie"])).toEqual([ + "src/useLottie.ts", + ]); + }); + + it("boosts a low-churn .d.ts that mentions an imported symbol", () => { + const files = [ + ...Array.from({ length: PATCH_PATH_CAP }, (_, i) => + patch(`dist/n${i}.d.ts`, { linesAdded: 40 }), + ), + patch("build/useLottie.d.ts", { linesAdded: 7 }), + ]; + const paths = selectPatchPaths(files, ["useLottie"]); + expect(paths[0]).toBe("build/useLottie.d.ts"); + }); + + it("caps total paths", () => { + const files = Array.from({ length: PATCH_PATH_CAP + 20 }, (_, i) => + patch(`src/f${i}.js`, { linesAdded: i }), + ); + expect(selectPatchPaths(files)).toHaveLength(PATCH_PATH_CAP); + }); +}); + +describe("shouldKeepPatch", () => { + it("keeps any fetched non-minified patch", () => { + expect(shouldKeepPatch("package.json", "@@\n+x\n")).toBe(true); + expect(shouldKeepPatch("src/a.js", "function other() {}")).toBe(true); + expect(shouldKeepPatch("src/a.js", null)).toBe(false); + expect( + shouldKeepPatch("dist/immer.production.mjs", "function produce() {}"), + ).toBe(false); + }); +}); + +describe("classifyNote / addedPatchLines / extractDate", () => { + it("buckets bun summary notes", () => { + expect(classifyNote('engines changed: { "node": ">=0.10.0" }')).toBe( + "peerEngine", + ); + expect(classifyNote("dependencies immer: 11.0.0 → 11.1.0")).toBe( + "peerEngine", + ); + expect(classifyNote("new install script: postinstall")).toBe("security"); + expect(classifyNote("new import of child_process")).toBe("security"); + expect(classifyNote("entry point main changed")).toBe("features"); + expect(classifyNote("breaking: removed produceWithPatches")).toBe( + "breaking", + ); + expect(classifyNote("4 files changed")).toBe(null); + }); + + it("takes list items under changelog headings", () => { + const added = [ + "## 2.0.0", + "### Breaking Changes", + "* removed produceWithPatches", + "### Features", + "* new enableArrayMethods", + "### Bug Fixes", + "* timezone RangeError", + ].join("\n"); + const classified = classifyChangelogLines(added); + expect(classified.breaking).toEqual(["removed produceWithPatches"]); + expect(classified.features).toEqual(["new enableArrayMethods"]); + expect(classified.security).toEqual([]); + }); + + it("does not treat a dep named fs as a new fs import", () => { + expect(classifyNote("dependencies graceful-fs: 4.0.0 → 4.2.0")).toBe( + "peerEngine", + ); + }); + + it("takes added lines and ISO dates from a changelog hunk", () => { + const hunk = [ + "--- a/CHANGELOG.md", + "+++ b/CHANGELOG.md", + "@@ -1,3 +1,8 @@", + "+## [1.11.23](https://example.com) (2026-08-17)", + "+", + "+### Bug Fixes", + "+* timezone RangeError", + " ## 1.11.22", + ].join("\n"); + const added = addedPatchLines(hunk); + expect(added).toContain("## [1.11.23]"); + expect(added).not.toContain("## 1.11.22"); + expect(extractDate(added)).toBe("2026-08-17"); + }); +}); + +describe("slimFiles", () => { + it("keeps named files ahead of a dts flood when over FILE_LIST_CAP", () => { + const files = [ + patch("CHANGELOG.md", { linesAdded: 1 }), + patch("package.json", { linesAdded: 1 }), + ...Array.from({ length: FILE_LIST_CAP }, (_, i) => + patch(`dist/n${i}.d.ts`, { linesAdded: 2 }), + ), + ]; + const slim = slimFiles(files); + expect(slim).toHaveLength(FILE_LIST_CAP); + expect(slim[0]?.path).toBe("CHANGELOG.md"); + expect(slim[1]?.path).toBe("package.json"); + }); + + it("drops maps, truncates kept patches, and caps the list", () => { + const files: BunPmDiffFile[] = [ + patch("dist/a.js.map", { patch: "MAP", sourceMap: true }), + patch("package.json", { patch: "x".repeat(PATCH_MAX_CHARS + 50) }), + ...Array.from({ length: FILE_LIST_CAP + 5 }, (_, i) => + patch(`src/n${i}.js`, { linesAdded: i, patch: `fn${i}` }), + ), + ]; + const slim = slimFiles(files); + expect(slim.every((f) => f.path !== "dist/a.js.map")).toBe(true); + const pkg = slim.find((f) => f.path === "package.json"); + expect(pkg?.patch?.length).toBe(PATCH_MAX_CHARS); + expect(slim.length).toBe(FILE_LIST_CAP); + expect(slim.some((f) => f.path === "package.json")).toBe(true); + }); +}); + +describe("buildDelta / failedDelta / npmVersionUrl", () => { + it("builds a bun-pm-diff delta from notes + changelog patch", () => { + const changelog = [ + "@@ -1,1 +1,6 @@", + "+## 2.0.0 (2026-01-02)", + "+### Breaking Changes", + "+* removed produceWithPatches", + "+### Features", + "+* new enableArrayMethods", + ].join("\n"); + const delta = buildDelta({ + target: "2.0.0", + changelogUrl: npmVersionUrl("immer", "2.0.0"), + stat: { + from: "immer@1.0.0", + to: "immer@2.0.0", + notes: [ + "dependencies zod: 3.0.0 → 4.0.0", + "new import of child_process", + ], + totals: { + files: 3, + added: 0, + deleted: 0, + linesAdded: 10, + linesRemoved: 2, + formattingOnly: 0, + }, + files: [ + patch("CHANGELOG.md", { linesAdded: 6 }), + patch("src/produce.js", { linesAdded: 3 }), + ], + }, + patches: { + files: [ + { path: "CHANGELOG.md", patch: changelog }, + { + path: "src/produce.js", + patch: "@@\n+export function produce() {}\n", + }, + ], + }, + }); + expect(delta.source).toBe("bun-pm-diff"); + expect(delta.error).toBeNull(); + expect(delta.date).toBe("2026-01-02"); + expect(delta.peerEngine.some((l) => /zod/.test(l))).toBe(true); + expect(delta.security.some((l) => /child_process/.test(l))).toBe(true); + expect(delta.breaking.some((l) => /produceWithPatches/.test(l))).toBe(true); + expect(delta.releaseNotes).toContain("2.0.0"); + expect( + delta.tarball?.files.find((f) => f.path === "src/produce.js")?.patch, + ).toContain("produce"); + expect(delta.changelogUrl).toBe( + "https://www.npmjs.com/package/immer/v/2.0.0", + ); + }); + + it("keeps the npm URL on a failed gather", () => { + const delta = failedDelta( + "1.2.3", + npmVersionUrl("@scope/pkg", "1.2.3"), + "bun pm diff exited 1", + ); + expect(delta.source).toBe("none"); + expect(delta.tarball).toBeNull(); + expect(delta.changelogUrl).toBe( + "https://www.npmjs.com/package/@scope/pkg/v/1.2.3", + ); + expect(delta.error).toContain("exited 1"); + }); +}); diff --git a/scripts/upgrade-packages/tarball-delta.ts b/scripts/upgrade-packages/tarball-delta.ts new file mode 100644 index 00000000..e88435f4 --- /dev/null +++ b/scripts/upgrade-packages/tarball-delta.ts @@ -0,0 +1,459 @@ +/** + * Slim + classify a `bun pm diff --json` payload for the upgrade-packages + * artifact. Pure — no registry, no gh. evidence.ts fetches; this file judges + * what the agent is allowed to read. + */ + +export const PATCH_MAX_CHARS = 8_000; +const RELEASE_NOTES_MAX_CHARS = 1_200; +export const FILE_LIST_CAP = 80; +export const PATCH_PATH_CAP = 40; +const HINT_CAP = 8; + +export interface TarballFile { + path: string; + status: string; + linesAdded: number; + linesRemoved: number; + formattingOnly: boolean; + patch: string | null; +} + +export interface TarballDiff { + from: string; + to: string; + notes: string[]; + totals: { + files: number; + added: number; + deleted: number; + linesAdded: number; + linesRemoved: number; + formattingOnly: number; + }; + files: TarballFile[]; +} + +export interface Delta { + version: string; + date: string | null; + breaking: string[]; + deprecations: string[]; + features: string[]; + security: string[]; + peerEngine: string[]; + releaseNotes: string | null; + changelogUrl: string; + tarball: TarballDiff | null; + source: "bun-pm-diff" | "none"; + error: string | null; +} + +export interface BunPmDiffFile { + path: string; + status?: string; + sourceMap?: boolean; + formattingOnly?: boolean; + linesAdded?: number; + linesRemoved?: number; + patch?: string; +} + +export interface BunPmDiffJson { + from?: string; + to?: string; + notes?: unknown; + totals?: { + files?: number; + added?: number; + deleted?: number; + linesAdded?: number; + linesRemoved?: number; + formattingOnly?: number; + }; + files?: BunPmDiffFile[]; +} + +const KEEP_NAME = + /^(package\.json|changelog.*|history(?:\.(md|txt))?|news(?:\.(md|txt))?|changes(?:\.(md|txt))?|readme.*)$/i; +const DTS_EXT = /\.d\.[cm]?ts$/i; +const CHANGELOG_NAME = /^(changelog|history|news|changes)/i; + +function fileName(path: string): string { + const i = path.lastIndexOf("/"); + return i >= 0 ? path.slice(i + 1) : path; +} + +export function isSkipFile( + file: Pick, +): boolean { + return Boolean(file.sourceMap) || file.path.endsWith(".map"); +} + +function isMinifiedPath(path: string): boolean { + return /\.(min|production)\.(m|c)?js$/i.test(path); +} + +function isKeepName(path: string): boolean { + return KEEP_NAME.test(fileName(path)); +} + +function isDtsPath(path: string): boolean { + return DTS_EXT.test(path); +} + +export function isKeepPath(path: string): boolean { + return isKeepName(path) || isDtsPath(path); +} + +function isChangelogPath(path: string): boolean { + return CHANGELOG_NAME.test(fileName(path)); +} + +export function npmVersionUrl(pkg: string, version: string): string { + return `https://www.npmjs.com/package/${pkg}/v/${version}`; +} + +function churn( + file: Pick, +): number { + return (file.linesAdded ?? 0) + (file.linesRemoved ?? 0); +} + +function byChurnDesc(a: BunPmDiffFile, b: BunPmDiffFile): number { + return churn(b) - churn(a); +} + +function pathMentionsSymbol(path: string, symbols: string[]): boolean { + return symbols.some((s) => s.length >= 2 && path.includes(s)); +} + +/** Paths to request on the second `bun pm diff` (patches only). */ +export function selectPatchPaths( + files: BunPmDiffFile[], + importedSymbols: string[] = [], +): string[] { + const usable = files.filter( + (f) => f.path && !isSkipFile(f) && !f.formattingOnly, + ); + const named = usable.filter((f) => isKeepName(f.path)); + const namedSet = new Set(named); + const dts = usable.filter((f) => isDtsPath(f.path) && !namedSet.has(f)); + const namedOrDts = new Set([...named, ...dts]); + const extra = usable.filter( + (f) => !namedOrDts.has(f) && !isMinifiedPath(f.path), + ); + const bySymbolThenChurn = (a: BunPmDiffFile, b: BunPmDiffFile): number => { + const aHit = pathMentionsSymbol(a.path, importedSymbols) ? 1 : 0; + const bHit = pathMentionsSymbol(b.path, importedSymbols) ? 1 : 0; + if (aHit !== bHit) return bHit - aHit; + return byChurnDesc(a, b); + }; + extra.sort(bySymbolThenChurn); + return [ + ...named.map((f) => f.path), + ...[...dts].sort(bySymbolThenChurn).map((f) => f.path), + ...extra.map((f) => f.path), + ].slice(0, PATCH_PATH_CAP); +} + +export function addedPatchLines(patch: string): string { + return patch + .split("\n") + .filter((l) => l.startsWith("+") && !l.startsWith("+++")) + .map((l) => l.slice(1)) + .join("\n"); +} + +export function extractDate(text: string): string | null { + const m = text.match(/\b(20\d{2}-\d{2}-\d{2})\b/); + return m ? m[1] : null; +} + +function extractHintLines(body: string, re: RegExp): string[] { + return body + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && re.test(l)) + .slice(0, HINT_CAP) + .map((l) => l.replace(/^[#*\-\s]+/, "").slice(0, 160)); +} + +const HEADING = /^(#{1,6}\s+|[A-Z][\w\s]{2,}:$)/; + +type HintKey = + | "breaking" + | "deprecations" + | "features" + | "security" + | "peerEngine"; + +interface HintBuckets { + breaking: string[]; + deprecations: string[]; + features: string[]; + security: string[]; + peerEngine: string[]; +} + +const HINT_KEYS: HintKey[] = [ + "breaking", + "deprecations", + "features", + "security", + "peerEngine", +]; + +const HEADING_BUCKET: [RegExp, HintKey][] = [ + [/breaking/i, "breaking"], + [/deprecat/i, "deprecations"], + [/security|advisory|\bcve\b/i, "security"], + [/\bfeat/i, "features"], + [/^#{1,6}\s+add/i, "features"], + [/peer|engine/i, "peerEngine"], +]; + +const DOC_HINTS: [HintKey, RegExp][] = [ + ["breaking", /breaking|breaking change/i], + ["deprecations", /deprecat|removed export/i], + ["features", /^feat|feature|^add|^new/i], + ["security", /security|cve|prototype pollution|vulnerabilit/i], + ["peerEngine", /peer dep|engine|requires (node|bun|react)/i], +]; + +function emptyHints(): HintBuckets { + return { + breaking: [], + deprecations: [], + features: [], + security: [], + peerEngine: [], + }; +} + +function headingBucket(line: string): HintKey | null { + for (const [re, key] of HEADING_BUCKET) { + if (re.test(line)) return key; + } + return null; +} + +export function classifyChangelogLines(added: string): HintBuckets { + const out = emptyHints(); + let section: HintKey | null = null; + for (const raw of added.split("\n")) { + const line = raw.trim(); + if (!line) continue; + if (HEADING.test(line)) { + section = headingBucket(line); + continue; + } + if (!section) continue; + const item = line.replace(/^[#*\-\s]+/, "").slice(0, 160); + if (item) out[section].push(item); + } + return out; +} + +export function classifyNote( + note: string, +): "security" | "peerEngine" | "features" | "breaking" | null { + if ( + /(preinstall|postinstall|preuninstall|install script|child_process|\bvm\b|eval\(|new Function|process\.env)/i.test( + note, + ) + ) { + return "security"; + } + // New `fs`/`net` imports are called out in notes; bare "fs" in a dep name is not. + if (/\b(fs|net|http)\b/.test(note) && /import/i.test(note)) return "security"; + if (/breaking/i.test(note)) return "breaking"; + if (/(engine|peer|dependenc)/i.test(note)) return "peerEngine"; + if (/(export|entry[- ]?point|binar|\bmain\b|\bmodule\b)/i.test(note)) { + return "features"; + } + return null; +} + +export function shouldKeepPatch(path: string, patch: string | null): boolean { + if (!patch) return false; + if (isMinifiedPath(path)) return false; + return true; +} + +function mergeDiffFiles( + stat: BunPmDiffJson, + patches: BunPmDiffJson | null, +): BunPmDiffFile[] { + const patchByPath = new Map(); + for (const f of patches?.files ?? []) { + if (f.path && typeof f.patch === "string") patchByPath.set(f.path, f.patch); + } + return (stat.files ?? []).map((f) => ({ + ...f, + patch: patchByPath.get(f.path) ?? f.patch, + })); +} + +export function slimFiles(files: BunPmDiffFile[]): TarballFile[] { + const mapped: TarballFile[] = []; + for (const f of files) { + if (!f.path || isSkipFile(f)) continue; + const rawPatch = typeof f.patch === "string" ? f.patch : null; + const keep = shouldKeepPatch(f.path, rawPatch); + mapped.push({ + path: f.path, + status: f.status ?? "modified", + linesAdded: f.linesAdded ?? 0, + linesRemoved: f.linesRemoved ?? 0, + formattingOnly: Boolean(f.formattingOnly), + patch: keep && rawPatch ? rawPatch.slice(0, PATCH_MAX_CHARS) : null, + }); + } + if (mapped.length <= FILE_LIST_CAP) return mapped; + const named = mapped.filter((f) => isKeepName(f.path)); + const namedSet = new Set(named); + const secondary = mapped.filter( + (f) => !namedSet.has(f) && (isDtsPath(f.path) || f.patch), + ); + const priority = [...named, ...secondary]; + const prioritySet = new Set(priority); + const rest = mapped + .filter((f) => !prioritySet.has(f)) + .sort( + (a, b) => b.linesAdded + b.linesRemoved - (a.linesAdded + a.linesRemoved), + ); + return [...priority, ...rest].slice(0, FILE_LIST_CAP); +} + +function uniqueHints(lines: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const l of lines) { + if (seen.has(l)) continue; + seen.add(l); + out.push(l); + if (out.length >= HINT_CAP) break; + } + return out; +} + +function notesOf(stat: BunPmDiffJson): string[] { + return Array.isArray(stat.notes) + ? stat.notes.filter((n): n is string => typeof n === "string") + : []; +} + +function appendNotes(out: HintBuckets, notes: string[]): void { + for (const note of notes) { + const bucket = classifyNote(note); + if (bucket) out[bucket].push(note); + } +} + +function appendDocHints(out: HintBuckets, added: string): void { + const fromSections = classifyChangelogLines(added); + for (const key of HINT_KEYS) out[key].push(...fromSections[key]); + for (const [key, re] of DOC_HINTS) { + out[key].push(...extractHintLines(added, re)); + } +} + +function collectFromFiles(files: TarballFile[]): { + hints: HintBuckets; + releaseNotes: string | null; + date: string | null; +} { + const hints = emptyHints(); + let releaseNotes: string | null = null; + let date: string | null = null; + for (const f of files) { + if (!f.patch) continue; + const added = addedPatchLines(f.patch); + if (isChangelogPath(f.path) || /^readme/i.test(fileName(f.path))) { + if (!releaseNotes && isChangelogPath(f.path)) { + releaseNotes = added.slice(0, RELEASE_NOTES_MAX_CHARS); + date = extractDate(added); + } + appendDocHints(hints, added); + } + if (fileName(f.path) === "package.json") { + hints.peerEngine.push( + ...extractHintLines(added, /peer|engine|dependenc/i), + ); + hints.security.push(...extractHintLines(added, /"(pre|post)?install"/i)); + } + } + return { hints, releaseNotes, date }; +} + +function tarballOf( + stat: BunPmDiffJson, + files: TarballFile[], + notes: string[], +): TarballDiff { + const totals = stat.totals ?? {}; + return { + from: stat.from ?? "", + to: stat.to ?? "", + notes, + totals: { + files: totals.files ?? files.length, + added: totals.added ?? 0, + deleted: totals.deleted ?? 0, + linesAdded: totals.linesAdded ?? 0, + linesRemoved: totals.linesRemoved ?? 0, + formattingOnly: totals.formattingOnly ?? 0, + }, + files, + }; +} + +export function failedDelta( + target: string, + changelogUrl: string, + error: string, +): Delta { + return { + version: target, + date: null, + breaking: [], + deprecations: [], + features: [], + security: [], + peerEngine: [], + releaseNotes: null, + changelogUrl, + tarball: null, + source: "none", + error, + }; +} + +export function buildDelta(args: { + target: string; + changelogUrl: string; + stat: BunPmDiffJson; + patches: BunPmDiffJson | null; +}): Delta { + const notes = notesOf(args.stat); + const files = slimFiles(mergeDiffFiles(args.stat, args.patches)); + const hints = emptyHints(); + appendNotes(hints, notes); + const fromFiles = collectFromFiles(files); + for (const key of HINT_KEYS) hints[key].push(...fromFiles.hints[key]); + return { + version: args.target, + date: fromFiles.date, + breaking: uniqueHints(hints.breaking), + deprecations: uniqueHints(hints.deprecations), + features: uniqueHints(hints.features), + security: uniqueHints(hints.security), + peerEngine: uniqueHints(hints.peerEngine), + releaseNotes: fromFiles.releaseNotes, + changelogUrl: args.changelogUrl, + tarball: tarballOf(args.stat, files, notes), + source: "bun-pm-diff", + error: null, + }; +} diff --git a/src/agents-init-interactive.test.ts b/src/agents-init-interactive.test.ts index a537621f..1b48bfac 100644 --- a/src/agents-init-interactive.test.ts +++ b/src/agents-init-interactive.test.ts @@ -15,7 +15,10 @@ describe("agents-init-interactive notes", () => { }); it("dims note body text like @clack/prompts 1.5", () => { - expect(styleText("dim", "hello")).toBe(styleText("dim", "hello")); - expect(styleText("dim", "hello")).not.toBe("hello"); + // Bun 1.4 `styleText` is a no-op without a TTY / when NO_COLOR is set + // (CI and this sandbox). Skip the stream check so we assert the dim codes. + const dimmed = styleText("dim", "hello", { validateStream: false }); + expect(dimmed).toBe(styleText("dim", "hello", { validateStream: false })); + expect(dimmed).not.toBe("hello"); }); });