From 21164381d4a57a103dab06f60a61054e3398dcb1 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 24 Aug 2026 13:45:16 +0300 Subject: [PATCH 1/3] chore: pin Bun 1.4.0 and isolate tests Keep published engines.bun at >=1.0.0. Add upgrade-packages evidence via bun pm diff, and fail check/CI when the lockfile is not deduped. --- .agents/skills/upgrade-packages/REFERENCE.md | 70 ++ .agents/skills/upgrade-packages/SKILL.md | 79 ++ .github/CONTRIBUTING.md | 2 +- .github/actions/setup/action.yml | 2 +- .github/workflows/ci.yml | 21 +- .oxfmtrc.json | 9 +- bun.lock | 50 +- knip.json | 2 +- lint-staged.config.js | 4 +- package.json | 10 +- scripts/upgrade-packages/.gitignore | 2 + scripts/upgrade-packages/evidence.test.ts | 97 ++ scripts/upgrade-packages/evidence.ts | 920 ++++++++++++++++++ .../upgrade-packages/tarball-delta.test.ts | 286 ++++++ scripts/upgrade-packages/tarball-delta.ts | 459 +++++++++ 15 files changed, 1963 insertions(+), 50 deletions(-) create mode 100644 .agents/skills/upgrade-packages/REFERENCE.md create mode 100644 .agents/skills/upgrade-packages/SKILL.md create mode 100644 scripts/upgrade-packages/.gitignore create mode 100644 scripts/upgrade-packages/evidence.test.ts create mode 100644 scripts/upgrade-packages/evidence.ts create mode 100644 scripts/upgrade-packages/tarball-delta.test.ts create mode 100644 scripts/upgrade-packages/tarball-delta.ts diff --git a/.agents/skills/upgrade-packages/REFERENCE.md b/.agents/skills/upgrade-packages/REFERENCE.md new file mode 100644 index 0000000..05009a3 --- /dev/null +++ b/.agents/skills/upgrade-packages/REFERENCE.md @@ -0,0 +1,70 @@ +# Reference — evidence artifact schema + +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 + +```jsonc +{ + "generatedAt": "ISO timestamp", + "inventory": { + "direct": [{ "name", "version", "range": "exact|caret|tilde", "dev" }], + "transitiveDuplicates": [{ "pkg", "versions": [...] }] // direct-dep conflicts + semver-major splits only + }, + "outdated": [{ + "pkg", "current", "latest", + "bumpClass": "patch|minor|major|prerelease|no-op", + "coupledWith": [], // naive; agent confirms peer/dep coupling from deltas + "dev" + }], + "audit": { + "bunAudit": , + "ghsa": [{ "pkg", "advisories": [{ + "id", "cveId", "severity", + "vulnerableRange", "fixedIn", + "installedInRange": bool, // script-computed vs installed version + "verdict": "priority-bump|needs-higher-target|cleared-at-current|unpatched|check-failed", + "url", // github.com/advisories/; empty + id:"error" on check-failed + "error" // optional — message on check-failed (gh/parse/cache failure) + }] }] + }, + "deltas": { + "": [{ + "version", "date", // target; date from changelog hunk if present + "breaking": [...], "deprecations": [...], "features": [...], + "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" + }] + }, + "usage": { + "": { + "importedSymbols": [...], "typeOnlySymbols": [...], // parsed imports (codemap) — type-only included + "sites": ["file:line", ...], // import locations + "callSites": ["file:line", ...], // reference locations (codemap only — blast radius) + "source": "codemap|grep" // grep = fallback when codemap unavailable + } + } +} +``` + +## How to read it + +- **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** — `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 new file mode 100644 index 0000000..1c617e6 --- /dev/null +++ b/.agents/skills/upgrade-packages/SKILL.md @@ -0,0 +1,79 @@ +--- +name: upgrade-packages +description: >- + Delta-driven dependency upgrades — a script gathers the evidence, you read the artifact and judge. Use when the user asks to upgrade, bump, or CVE-audit dependencies. +--- + +# Upgrade packages + +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 `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`. + +## Phase 2 — Triage (judge the artifact) + +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 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 `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), 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. + +## Phase 4 — Apply, gated by risk + +Bands: patch → minor → major, verify after each per [`verify-after-each-step`](../../rules/verify-after-each-step.md). Within each band, **priority-bump** packages first; **coupled sets** move up together; **prereleases** go in the patch band (moving-target, same-major-line gate). + +1. **Patch** — bump together (`bun update @` per package); run the CI mirror (below). +2. **Minor** — bump together; same checks. +3. **Major** — one at a time; land its `breaks` code change _first_, bump, then checks. Defer unresolved majors (cited reason) unless they clear a high/critical advisory. + +A bumped codec/backend/source peer (zod / seroval / idb-keyval / store adapters) can change persist semantics — re-run `bun run test:dom` when the bump touches a framework adapter. After a version changeset, `bun scripts/sync-skill-versions.ts` stays in the `version` script. Re-run `bun audit` after each band — a **new** advisory → revert that bump and re-research. Commit per band **only when the user asked to commit**. + +**Done when:** CI mirror (`bun run check` + `bun run build`) green for patch + minor; every major green-and-committed/staged or deferred with a cited reason; no `breaks` unaddressed; final `bun audit` clean or every remaining advisory documented. + +## Phase 5 — Verify (local CI mirror) + +`package.json` `check` and `.github/workflows/ci.yml` are the SSOT. Do not restate their job lists here. + +- `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` 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. + +## Anti-patterns + +- ❌ 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 + +- [`REFERENCE.md`](./REFERENCE.md) — evidence artifact schema + how to read it +- [`verify-after-each-step`](../../rules/verify-after-each-step.md) — per-band checks +- [`harden-pr`](../harden-pr/SKILL.md) — full mode before PR diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3aefcd7..677b26e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ `@stainless-code/persist` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: - **Public surface** — anything exported from an entry point (`src/core/index.ts` and the opt-in adapter subpaths) is the public API and must carry JSDoc that reads well in hovers and published typings. See [`docs/architecture.md`](../docs/architecture.md) for the seam model. -- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test on both `persist-core.ts` and `hydration.ts`); each subpath owns its optional peer. +- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design (enforced by a gate test on both `persist-core.ts` and `hydration.ts`); each subpath owns its optional peer. Maintainers use **Bun 1.4.0** (`packageManager`). ## Dev workflow diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 5809e52..d85ca63 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,7 +7,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 9d2d547..6ac11a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,23 @@ jobs: - name: Audit docs site run: bun run docs:audit + dedupe: + 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 decides. # An advisory-API outage doesn't block; malware is out of scope @@ -264,6 +281,7 @@ jobs: size, docs, audit, + dedupe, ] if: always() runs-on: ubuntu-latest @@ -280,7 +298,8 @@ jobs: needs['check-pack'].result != 'success' || needs.size.result != 'success' || needs.docs.result != 'success' || - needs.audit.result != 'success' + needs.audit.result != 'success' || + needs.dedupe.result != 'success' ) run: exit 1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 35a8670..15478f5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -4,6 +4,13 @@ "sortPackageJson": { "sortScripts": true }, - "ignorePatterns": ["node_modules", "dist", "coverage", "bun.lock"], + "ignorePatterns": [ + "node_modules", + "dist", + "coverage", + "bun.lock", + "scripts/upgrade-packages/.cache", + "scripts/upgrade-packages/artifact.json" + ], "printWidth": 80 } diff --git a/bun.lock b/bun.lock index ccdfcf5..e6e7971 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/alpinejs": "3.13.11", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -660,8 +660,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -1042,7 +1040,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1366,7 +1364,7 @@ "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], - "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], @@ -1380,7 +1378,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "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=="], @@ -3046,15 +3044,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=="], @@ -3140,13 +3138,13 @@ "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "@astrojs/telemetry/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "@astrojs/telemetry/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "@astrojs/vercel/@vercel/analytics": ["@vercel/analytics@1.6.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg=="], @@ -3180,8 +3178,6 @@ "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@jest/types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -3268,7 +3264,7 @@ "astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], - "astro/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "astro/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3286,14 +3282,8 @@ "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "bun-types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - - "chrome-launcher/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "chrome-launcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "chromium-edge-launcher/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "chromium-edge-launcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -3350,8 +3340,6 @@ "is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - "jest-util/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -3360,8 +3348,6 @@ "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - "jest-worker/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -3430,7 +3416,7 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "publint/package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "publint/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -3460,8 +3446,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=="], - "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -3496,10 +3480,6 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "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=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], @@ -3542,8 +3522,6 @@ "astro/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], - "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "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=="], @@ -3716,14 +3694,8 @@ "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=="], - "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "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=="], - "@expo/cli/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@expo/cli/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/knip.json b/knip.json index dcb5934..8c48ebf 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "entry": [ - "scripts/*.ts", + "scripts/**/*.ts", "tests-dom/*.test.tsx", "vitest.config.ts", "tsdown.config.ts", diff --git a/lint-staged.config.js b/lint-staged.config.js index 87f4b66..fc80f6e 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(" ")}`; } /** Pick the test runner for staged `*.test.{ts,tsx}` by location: @@ -74,7 +74,7 @@ function runStagedTest(filenames) { const src = files.filter((f) => f.startsWith("src/")); const tasks = []; if (dom) tasks.push("bun run test:dom"); - if (src.length) tasks.push(`bun test ${src.join(" ")}`); + if (src.length) tasks.push(`bun test --isolate ${src.join(" ")}`); return tasks.length > 0 ? tasks.join(" && ") : "true"; } diff --git a/package.json b/package.json index b765222..48fed1f 100644 --- a/package.json +++ b/package.json @@ -175,10 +175,11 @@ }, "scripts": { "build": "tsdown", - "check": "bun run build && bun run --parallel format:check lint:ci test test:dom typecheck", + "check": "bun run build && bun run --parallel format:check lint:ci test test:dom typecheck dedupe:check", "check-updates": "bun update -i --latest", "check:pack": "attw --pack . --profile esm-only && publint && knip", "clean": "git clean -xdf -e .env", + "dedupe:check": "bun dedupe --check", "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/persist-docs' audit", "docs:build": "bun run --filter '@stainless-code/persist-docs' build", @@ -203,10 +204,11 @@ "prepublishOnly": "bun run check && bun run intent:validate && bun run check:pack && bun run size && bun run test:coverage", "release": "changeset publish", "size": "size-limit", - "test": "bun test ./src", + "test": "bun test --isolate ./src scripts/upgrade-packages", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.90", "test:dom": "vitest run", "typecheck": "tsgo --noEmit", + "upgrade-packages:evidence": "bun run scripts/upgrade-packages/evidence.ts", "version": "changeset version && bun scripts/sync-skill-versions.ts && bun run format CHANGELOG.md" }, "devDependencies": { @@ -223,7 +225,7 @@ "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/alpinejs": "3.13.11", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -358,5 +360,5 @@ "bun": ">=1.0.0", "node": "^20.19.0 || >=22.12.0" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.4.0" } diff --git a/scripts/upgrade-packages/.gitignore b/scripts/upgrade-packages/.gitignore new file mode 100644 index 0000000..ce768e8 --- /dev/null +++ b/scripts/upgrade-packages/.gitignore @@ -0,0 +1,2 @@ +artifact.json +.cache/ diff --git a/scripts/upgrade-packages/evidence.test.ts b/scripts/upgrade-packages/evidence.test.ts new file mode 100644 index 0000000..cbcbf82 --- /dev/null +++ b/scripts/upgrade-packages/evidence.test.ts @@ -0,0 +1,97 @@ +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("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 new file mode 100644 index 0000000..11450bf --- /dev/null +++ b/scripts/upgrade-packages/evidence.ts @@ -0,0 +1,920 @@ +/** + * 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 (tarball notes + * and kept patches, changelogUrl, advisory URL) are artifact fields, so + * model priors can't sneak in. + * + * Usage: + * bun run upgrade-packages:evidence [--out ] [--only ] + * bun run upgrade-packages:evidence --only immer # tracer bullet + * + * 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. + */ + +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 +// ──────────────────────────────────────────────────────────────────────────── + +type BumpClass = "patch" | "minor" | "major" | "prerelease" | "no-op"; + +interface OutdatedPkg { + pkg: string; + current: string; + latest: string; + bumpClass: BumpClass; + coupledWith: string[]; // peer/dep that forces a higher band (filled naively here) + dev: boolean; +} + +interface AdvisoryVuln { + id: string; // GHSA id + cveId: string | null; + severity: string; + vulnerableRange: string | null; + fixedIn: string | null; + installedInRange: boolean; // script-computed vs installed version + verdict: + | "priority-bump" + | "needs-higher-target" + | "cleared-at-current" + | "unpatched" + | "check-failed"; // gh/parse/cache failure — inconclusive, not a real vuln + url: string; + error?: string; +} + +interface Usage { + importedSymbols: string[]; + typeOnlySymbols: string[]; + sites: string[]; // import file:line + callSites: string[]; // reference file:line (codemap only) + source: "codemap" | "grep"; +} + +interface Evidence { + generatedAt: string; + inventory: { + direct: { + name: string; + version: string; + range: "exact" | "caret" | "tilde"; + dev: boolean; + }[]; + transitiveDuplicates: { pkg: string; versions: string[] }[]; + }; + outdated: OutdatedPkg[]; + audit: { + bunAudit: unknown; // raw bun audit --json payload + ghsa: { pkg: string; advisories: AdvisoryVuln[] }[]; + }; + deltas: Record; + usage: Record; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Shell helpers +// ──────────────────────────────────────────────────────────────────────────── + +async function run( + cmd: string[], + opts: { cwd?: string; retries?: number } = {}, +): Promise { + const retries = opts.retries ?? 0; + for (let attempt = 0; attempt <= retries; attempt++) { + if (cmd[0] === "gh") await ghGate(); + const proc = Bun.spawn(cmd, { + stdout: "pipe", + stderr: "pipe", + cwd: opts.cwd ?? process.cwd(), + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + if (code === 0) return stdout; + if (attempt < retries) { + // gh secondary rate-limit / transient failures: back off and retry. + await new Promise((r) => setTimeout(r, 3000 * (attempt + 1))); + continue; + } + throw new Error(`${cmd.join(" ")} exited ${code}\n${stderr.slice(0, 500)}`); + } + throw new Error("unreachable"); +} + +/** Run a command, return stdout even on non-zero exit (for tolerant gatherers). */ +async function runSoft( + cmd: string[], +): Promise<{ ok: boolean; stdout: string; code: number }> { + const proc = Bun.spawn(cmd, { + stdout: "pipe", + stderr: "pipe", + cwd: process.cwd(), + }); + // Drain stderr alongside stdout — a full pipe buffer deadlocks the subprocess. + const [stdout, , code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: code === 0, stdout, code }; +} + +// Pace `gh` calls to avoid GitHub's secondary rate limit (burst protection). +let lastGhCall = 0; +const GH_MIN_GAP_MS = 1000; +async function ghGate() { + const wait = GH_MIN_GAP_MS - (Date.now() - lastGhCall); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastGhCall = Date.now(); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Disk cache for gh release/advisory data — iterative runs don't re-fetch, +// so repeated runs (and re-runs after a rate-limit) are fast and don't re-trip it. +// ──────────────────────────────────────────────────────────────────────────── + +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 — 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, + maxAgeMs = CACHE_MAX_AGE_MS, +): Promise { + const f = Bun.file(cachePath(key)); + if (!(await f.exists())) return null; + if (Date.now() - f.lastModified > maxAgeMs) return null; + return await f.text(); +} + +async function writeCache(key: string, data: string): Promise { + try { + // Bun.write auto-creates parent directories. + await Bun.write(cachePath(key), data); + } catch { + // cache is best-effort + } +} + +/** bun emits a `[Xms] ".env"` header before JSON output — strip leading non-JSON lines. */ +function stripBunHeader(s: string): string { + const lines = s.split("\n"); + let i = 0; + while ( + i < lines.length && + !lines[i].trim().startsWith("{") && + !lines[i].trim().startsWith("[") + ) + i++; + return lines.slice(i).join("\n"); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Semver +// ──────────────────────────────────────────────────────────────────────────── + +function parseVer(v: string): number[] { + const core = v.split(/[-+]/)[0]; + return core.split(".").map((n) => Number(n) || 0); +} + +function cmpVer(a: string, b: string): number { + const pa = parseVer(a); + const pb = parseVer(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +function isPrerelease(v: string): boolean { + return /-(dev|canary|next|beta|alpha|rc|preview)/i.test(v); +} + +/** 0.x semver: the second digit is the minor. */ +function bumpClass(current: string, latest: string): BumpClass { + if (cmpVer(current, latest) === 0) return "no-op"; + if (isPrerelease(latest) || isPrerelease(current)) return "prerelease"; + const [ca, cb] = parseVer(current); + const [la, lb] = parseVer(latest); + if (la !== ca) return "major"; + // 0.x: second digit is the minor + if (ca === 0) return lb !== cb ? "minor" : "patch"; + return lb !== cb ? "minor" : "patch"; +} + +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). + // Not a full semver-range parser — `semver` is not added as a dep for a dev script. + const orGroups = range.split("||"); + for (const group of orGroups) { + // Split on commas OR whitespace between comparators (e.g. ">= 7.0.0 < 9.0.6"). + const clauses = group + .split(/,|\s+(?=(?:>=|<=|>|<|=))/) + .map((c) => c.trim()) + .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) { + groupOk = false; + break; + } + parsed += 1; + const [, op, ver] = m; + const c = cmpVer(version, ver); + if (op === ">=" && !(c >= 0)) groupOk = false; + if (op === ">" && !(c > 0)) groupOk = false; + if (op === "<=" && !(c <= 0)) groupOk = false; + if (op === "<" && !(c < 0)) groupOk = false; + if ((!op || op === "=") && c !== 0) groupOk = false; + } + 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 +// ──────────────────────────────────────────────────────────────────────────── + +const HIGH_RISK = [ + "zod", + "seroval", + "idb-keyval", + "expo-secure-store", + "react", + "vitest", + "jsdom", + "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 direct: Evidence["inventory"]["direct"] = []; + const classify = (v: string): "exact" | "caret" | "tilde" => + v.startsWith("^") ? "caret" : v.startsWith("~") ? "tilde" : "exact"; + 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). + const lock = await Bun.file("bun.lock") + .text() + .catch(() => ""); + const versionMap = new Map>(); + // bun.lock text format: `"name@version"` lines — collect all name@version. + for (const m of lock.matchAll(/"(@?[^"@]+)@([^"@]+)"/g)) { + const [, name, ver] = m; + if (!versionMap.has(name)) versionMap.set(name, new Set()); + versionMap.get(name)!.add(ver); + } + const directNames = new Set(direct.map((d) => d.name)); + const transitiveDuplicates: Evidence["inventory"]["transitiveDuplicates"] = + []; + for (const [name, versions] of versionMap) { + if (versions.size < 2) continue; + const vers = [...versions]; + const hasDirect = directNames.has(name); + const majorSplit = new Set(vers.map((v) => parseVer(v)[0])).size > 1; + // Skill rule: flag only direct-dep conflicts or semver-major splits. + if (hasDirect || majorSplit) { + transitiveDuplicates.push({ + pkg: name, + versions: vers.sort((a, b) => cmpVer(a, b)), + }); + } + } + return { direct, transitiveDuplicates }; +} + +export function parseOutdatedTable(stdout: string): OutdatedPkg[] { + const out: OutdatedPkg[] = []; + 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")) { + 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); + try { + return JSON.parse(body); + } catch { + return { + error: "could not parse bun audit output", + raw: body.slice(0, 500), + }; + } +} + +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, + target: Map, +): Promise { + const out: Evidence["audit"]["ghsa"] = []; + for (const pkg of pkgs) { + try { + const list = await fetchGhsaList(pkg); + out.push({ + pkg, + advisories: list.map((a) => + advisoryFromGhsa( + a as Parameters[0], + pkg, + installed, + target, + ), + ), + }); + } catch (e) { + out.push(ghsaCheckFailed(pkg, e)); + } + } + return out; +} + +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`); + } + 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; +} + +/** 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 { + 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 [ + failedDelta( + target, + changelogUrl, + e instanceof Error ? e.message : String(e), + ), + ]; + } +} + +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]!); + } + } + const n = Math.min(concurrency, items.length); + if (n === 0) return out; + await Promise.all(Array.from({ length: n }, () => worker())); + return out; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Codemap (parsed imports + call sites) — primary usage source, grep fallback +// ──────────────────────────────────────────────────────────────────────────── + +let codemapAvailable: boolean | null = null; + +async function checkCodemap(): Promise { + if (codemapAvailable !== null) return codemapAvailable; + const { ok } = await runSoft([ + "bunx", + "codemap", + "query", + "--json", + "SELECT 1 AS ok", + ]); + codemapAvailable = ok; + return codemapAvailable; +} + +async function codemapQuery(sql: string): Promise { + const raw = stripBunHeader( + await run(["bunx", "codemap", "query", "--json", sql], { + retries: 1, + }), + ); + try { + return JSON.parse(raw); + } catch { + return []; + } +} + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +function parseSpecifiers(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.filter(Boolean); + } catch { + // fall through + } + return String(raw) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Batched codemap usage: 2 SQL calls total for ALL packages (vs 2 per package). + * 1) all imports whose source matches any outdated pkg (exact or subpath) + * 2) all imported references in any of those importing files + * Then bucket per package in JS, scoping callSites to each pkg's importing files + specifiers. + */ +async function gatherAllUsage(pkgs: string[]): Promise> { + const result = new Map(); + if (!pkgs.length) return result; + if (!(await checkCodemap())) return result; // caller falls back to grep per package + + // Build WHERE: source IN (pkgs) OR source LIKE 'pkg/%' OR ... + const inList = pkgs.map((p) => `'${sqlEscape(p)}'`).join(","); + const likeClauses = pkgs + .map((p) => `source LIKE '${sqlEscape(p)}/%'`) + .join(" OR "); + const importRows = await codemapQuery( + `SELECT source, file_path, line_number, specifiers, is_type_only FROM imports WHERE source IN (${inList}) OR ${likeClauses}`, + ); + + // Bucket imports per package (exact source, or subpath source.startsWith(pkg + '/')) + const perPkg = new Map< + string, + { + sites: Set; + imported: Set; + typeOnly: Set; + files: Set; + } + >(); + for (const p of pkgs) + perPkg.set(p, { + sites: new Set(), + imported: new Set(), + typeOnly: new Set(), + files: new Set(), + }); + + for (const r of importRows) { + const pkg = pkgs.find( + (p) => r.source === p || r.source.startsWith(p + "/"), + ); + if (!pkg) continue; + const bucket = perPkg.get(pkg)!; + bucket.sites.add(`${r.file_path}:${r.line_number}`); + bucket.files.add(r.file_path); + for (const s of parseSpecifiers(r.specifiers)) { + (r.is_type_only ? bucket.typeOnly : bucket.imported).add(s); + } + } + + // Batched references query: all imported refs in any importing file, with name. + const allFiles = new Set(); + const allSpecs = new Set(); + for (const b of perPkg.values()) { + for (const f of b.files) allFiles.add(f); + for (const s of b.imported) allSpecs.add(s); + for (const s of b.typeOnly) allSpecs.add(s); + } + const refByFile = new Map(); + if (allFiles.size && allSpecs.size) { + const fileList = [...allFiles].map((f) => `'${sqlEscape(f)}'`).join(","); + const specList = [...allSpecs].map((s) => `'${sqlEscape(s)}'`).join(","); + const refRows = await codemapQuery( + `SELECT r.file_path, r.line_start, r.name FROM "references" r JOIN bindings b ON b.reference_id = r.id WHERE b.resolution_kind='imported' AND r.name IN (${specList}) AND r.file_path IN (${fileList})`, + ); + for (const r of refRows) { + const key = r.file_path; + if (!refByFile.has(key)) refByFile.set(key, []); + refByFile.get(key)!.push({ name: r.name, line: r.line_start }); + } + } + + for (const [pkg, b] of perPkg) { + const specs = new Set([...b.imported, ...b.typeOnly]); + const callSites = new Set(); + for (const f of b.files) { + for (const ref of refByFile.get(f) ?? []) { + if (specs.has(ref.name)) callSites.add(`${f}:${ref.line}`); + } + } + result.set(pkg, { + importedSymbols: [...b.imported].slice(0, 30), + typeOnlySymbols: [...b.typeOnly].slice(0, 30), + sites: [...b.sites].slice(0, 30), + callSites: [...callSites].slice(0, 30), + source: "codemap", + }); + } + return result; +} + +async function grepUsage(pkg: string): Promise { + // Fallback when codemap is unavailable. Match `from ""` / `from "/subpath`. + const pattern = `from ['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/[^'"]*)?['"]`; + const { stdout } = await runSoft([ + "rg", + "-n", + "--type", + "ts", + "-g", + "!**/node_modules/**", + pattern, + ]); + const sites: string[] = []; + const imported = new Set(); + for (const line of stdout.split("\n").filter(Boolean)) { + const m = line.match(/^([^:]+):(\d+):.*(import\s+([^]*?)\s+from)/); + if (m) { + sites.push(`${m[1]}:${m[2]}`); + m[4] + .replace(/[{}\s]/g, "") + .split(",") + .forEach((s) => s && imported.add(s)); + } + } + return { + importedSymbols: [...imported].slice(0, 20), + typeOnlySymbols: [], + sites: sites.slice(0, 12), + callSites: [], + source: "grep", + }; +} + +/** Gather usage for a set of packages: batched codemap, with per-package grep fallback. */ +async function gatherAllUsageWithFallback( + pkgs: string[], +): Promise> { + try { + const mapped = await gatherAllUsage(pkgs); + if (mapped.size === pkgs.length) return mapped; + // codemap returned partial — fill gaps with grep + for (const p of pkgs) { + if (!mapped.has(p)) mapped.set(p, await grepUsage(p)); + } + return mapped; + } catch { + const mapped = new Map(); + for (const p of pkgs) mapped.set(p, await grepUsage(p)); + return mapped; + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Main +// ──────────────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const onlyIdx = args.indexOf("--only"); + const onlyPkg = onlyIdx >= 0 ? args[onlyIdx + 1] : null; + const outIdx = args.indexOf("--out"); + const outPath = outIdx >= 0 ? args[outIdx + 1] : DEFAULT_OUT; + + console.error("→ inventory"); + const inventory = await parsePackageJson(); + const installed = new Map( + inventory.direct.map((d) => [d.name, d.version.replace(/^[~^]/, "")]), + ); + + console.error("→ bun outdated"); + let outdated = await parseBunOutdated(); + if (onlyPkg) outdated = outdated.filter((o) => o.pkg === onlyPkg); + const target = new Map(outdated.map((o) => [o.pkg, o.latest])); + + console.error("→ bun audit + ghsa"); + const ghsaPkgs = (onlyPkg ? [onlyPkg] : HIGH_RISK).filter( + (p) => installed.has(p) || target.has(p), + ); + 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) { + 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] = list; + + const evidence: Evidence = { + generatedAt: new Date().toISOString(), + inventory, + outdated, + audit: { bunAudit, ghsa }, + deltas, + usage, + }; + + const json = JSON.stringify(evidence, null, 2); + if (outPath) { + await Bun.write(outPath, json); + console.error(`✓ wrote ${outPath}`); + } else { + console.log(json); + } +} + +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 0000000..09f436d --- /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 0000000..e88435f --- /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, + }; +} From d13cbb7a7d7ff80afaee81903f413a4e863b7e8b Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 24 Aug 2026 13:50:45 +0300 Subject: [PATCH 2/3] fix: keep CI green on Bun 1.4 Scope knip entries to the root workspace so src tests stay visible, ignore bun's dedupe builtin, and pin transitive tar to 7.5.22 so audit no longer blocks on GHSA-r292-9mhp-454m. --- bun.lock | 3 ++- knip.json | 18 +++++++++++------- package.json | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index e6e7971..f6c0a36 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,7 @@ "js-yaml": "4.3.1", "nanoid": "3.3.18", "path-to-regexp": "6.3.0", + "tar": "7.5.22", "uuid": ">=11.1.1", }, "packages": { @@ -2854,7 +2855,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=="], diff --git a/knip.json b/knip.json index 8c48ebf..a02dab5 100644 --- a/knip.json +++ b/knip.json @@ -1,13 +1,17 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": [ - "scripts/**/*.ts", - "tests-dom/*.test.tsx", - "vitest.config.ts", - "tsdown.config.ts", - "lint-staged.config.js" - ], + "workspaces": { + ".": { + "entry": [ + "scripts/*.ts", + "scripts/upgrade-packages/*.ts", + "src/**/*.test.ts", + "tests-dom/*.test.tsx" + ] + } + }, "ignore": [".codemap/**"], "ignoreDependencies": ["@stainless-code/codemap"], + "ignoreBinaries": ["dedupe"], "ignoreWorkspaces": ["apps/*"] } diff --git a/package.json b/package.json index 48fed1f..2742874 100644 --- a/package.json +++ b/package.json @@ -354,6 +354,7 @@ "js-yaml": "4.3.1", "nanoid": "3.3.18", "path-to-regexp": "6.3.0", + "tar": "7.5.22", "uuid": ">=11.1.1" }, "engines": { From 1ae54750dd0725554548dee94a99b4ad148b7a0a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 24 Aug 2026 13:56:05 +0300 Subject: [PATCH 3/3] fix: use resolved versions for GHSA and keep workspace deltas Overlay bun outdated current onto the inventory floor before advisory checks, and merge same-package delta lists instead of overwriting. --- .agents/skills/upgrade-packages/REFERENCE.md | 2 +- scripts/upgrade-packages/evidence.test.ts | 12 ++++++++++++ scripts/upgrade-packages/evidence.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.agents/skills/upgrade-packages/REFERENCE.md b/.agents/skills/upgrade-packages/REFERENCE.md index 05009a3..28b8860 100644 --- a/.agents/skills/upgrade-packages/REFERENCE.md +++ b/.agents/skills/upgrade-packages/REFERENCE.md @@ -63,7 +63,7 @@ Cutter caps (`tarball-delta.ts`): `PATCH_PATH_CAP` 40 paths on the second `bun p ## How to read it -- **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`. +- **Verdict a package**: read `outdated[].bumpClass` + `audit.ghsa[].verdict` + `tarball.notes` + `deltas[][].breaking`/`security`/`deprecations`/`peerEngine` + `usage[]` (`importedSymbols`, `typeOnlySymbols`, `sites`, `callSites`). Phase 3 of the skill is mandatory for every break-risk delta. 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. diff --git a/scripts/upgrade-packages/evidence.test.ts b/scripts/upgrade-packages/evidence.test.ts index cbcbf82..fa03cc1 100644 --- a/scripts/upgrade-packages/evidence.test.ts +++ b/scripts/upgrade-packages/evidence.test.ts @@ -68,6 +68,18 @@ describe("parseOutdatedTable", () => { ]); }); + 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/persist |", + "| react | 19.1.0 | 19.2.0 | 19.2.0 | @stainless-code/persist-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 |", diff --git a/scripts/upgrade-packages/evidence.ts b/scripts/upgrade-packages/evidence.ts index 11450bf..d33e334 100644 --- a/scripts/upgrade-packages/evidence.ts +++ b/scripts/upgrade-packages/evidence.ts @@ -859,6 +859,8 @@ 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 + ghsa"); const ghsaPkgs = (onlyPkg ? [onlyPkg] : HIGH_RISK).filter( @@ -892,7 +894,9 @@ async function main() { ] as const; }); const deltas: Record = {}; - for (const [pkg, list] of deltaEntries) deltas[pkg] = list; + for (const [pkg, list] of deltaEntries) { + deltas[pkg] = [...(deltas[pkg] ?? []), ...list]; + } const evidence: Evidence = { generatedAt: new Date().toISOString(),