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..a94b36b --- /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 adapter peer (react / vue / svelte / solid / lit / alpine / angular / preact) can change DOM-suite behavior — re-run `bun run test:dom`. Re-run `bun run check:deps` after workspace bumps (sherif). 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 7b208d7..e7ce87f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ `@stainless-code/layers` is a small, freshly extracted library. Before large PRs, please open an issue so we can align on: - **Public surface** — anything exported from a package entry point (`src/index.ts` / React's `src/index.tsx`, plus Svelte's `src/store.ts`) 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 core + adapter model. -- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.0.0` (`package.json` **engines**). The core is zero-dep by design; each adapter package declares its required peer (`react`, `preact`, `solid-js`, `@angular/core`, `vue`, `lit` + `@lit/context`, `svelte`). +- **Runtimes** — **Node** `^20.19.0 || >=22.12.0` and **Bun** `>=1.4.0` at the private monorepo root (`packageManager: bun@1.4.0`). Published adapter packages keep `engines.bun` `>=1.0.0`. The core is zero-dep by design; each adapter package declares its required peer (`react`, `preact`, `solid-js`, `@angular/core`, `vue`, `lit` + `@lit/context`, `svelte`). ## 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 a50de0b..c6c4c2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,11 @@ jobs: - name: Run unit tests with coverage gate run: bun run test:coverage + # Workspace `test:coverage` is per-package `src/` only. Root + # `scripts/upgrade-packages` is on `bun run test`, not the coverage gate. + - name: Run upgrade-packages tests + run: bun test --isolate scripts/upgrade-packages + test-dom: name: 🌐 Test (DOM) needs: skip-ci 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 79a41b0..ef6ba30 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@size-limit/preset-small-lib": "13.0.3", "@stainless-code/codemap": "0.11.4", "@tanstack/intent": "0.3.6", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.2", "husky": "9.1.7", "knip": "6.32.0", @@ -1338,7 +1338,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=="], @@ -1696,7 +1696,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=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/package.json b/package.json index bc3a180..a48f3ee 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "prepare": "husky || true", "release": "bun scripts/release.ts", "size": "size-limit", - "test": "bun run --filter '*' test", + "test": "bun run --filter '*' test && bun test --isolate scripts/upgrade-packages", "test:coverage": "bun run --filter '*' test:coverage", "test:dom": "bun run --filter '*' test:dom", "test:types": "bun run --filter '*' typecheck", @@ -68,7 +68,7 @@ "@size-limit/preset-small-lib": "13.0.3", "@stainless-code/codemap": "0.11.4", "@tanstack/intent": "0.3.6", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/node": "26.1.2", "husky": "9.1.7", "knip": "6.32.0", @@ -91,8 +91,8 @@ "yuku-parser": "0.6.5" }, "engines": { - "bun": ">=1.0.0", + "bun": ">=1.4.0", "node": "^20.19.0 || >=22.12.0" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.4.0" } diff --git a/packages/alpine/package.json b/packages/alpine/package.json index a77f3e9..da266ad 100644 --- a/packages/alpine/package.json +++ b/packages/alpine/package.json @@ -59,7 +59,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/angular/package.json b/packages/angular/package.json index c2ae6a9..4c3d798 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/core/package.json b/packages/core/package.json index 5228544..1b64ae9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "typecheck": "tsc --noEmit" }, diff --git a/packages/devtools/package.json b/packages/devtools/package.json index f4a6bbb..db57961 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "typecheck": "tsc --noEmit" }, diff --git a/packages/lit/package.json b/packages/lit/package.json index a647294..6057909 100644 --- a/packages/lit/package.json +++ b/packages/lit/package.json @@ -52,7 +52,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/preact/package.json b/packages/preact/package.json index f32e385..5ab367e 100644 --- a/packages/preact/package.json +++ b/packages/preact/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index ed6d8f4..a002d3b 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -54,7 +54,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "bun run --filter '@stainless-code/layers' --filter '@stainless-code/layers-devtools' --filter '@stainless-code/react-layers' build && tsc --noEmit" diff --git a/packages/react/package.json b/packages/react/package.json index c50420d..62428da 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -52,7 +52,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/solid/package.json b/packages/solid/package.json index 8910fcc..7197061 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 4da5224..e44645d 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -56,7 +56,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" diff --git a/packages/vue/package.json b/packages/vue/package.json index b36d30e..f04e93e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -51,7 +51,7 @@ "scripts": { "build": "tsdown", "check:pack": "attw --pack . --profile esm-only && publint --strict", - "test": "bun test ./src", + "test": "bun test --isolate ./src", "test:coverage": "bun test ./src --coverage --coverage-threshold=0.75", "test:dom": "vitest run", "typecheck": "tsc --noEmit" 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 index 95da142..6a6be14 100644 --- a/scripts/upgrade-packages/evidence.ts +++ b/scripts/upgrade-packages/evidence.ts @@ -1,8 +1,35 @@ -// The upgrade skill judges this artifact instead of querying external sources; -// source URLs keep its conclusions grounded in captured evidence. +/** + * 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 { @@ -10,46 +37,32 @@ interface OutdatedPkg { current: string; latest: string; bumpClass: BumpClass; - coupledWith: string[]; + coupledWith: string[]; // peer/dep that forces a higher band (filled naively here) + dev: boolean; } interface AdvisoryVuln { - id: string; + id: string; // GHSA id cveId: string | null; severity: string; vulnerableRange: string | null; fixedIn: string | null; - installedInRange: boolean; + installedInRange: boolean; // script-computed vs installed version verdict: | "priority-bump" | "needs-higher-target" | "cleared-at-current" | "unpatched" - | "check-failed"; // Collection failure is inconclusive, not a vulnerability. + | "check-failed"; // gh/parse/cache failure — inconclusive, not a real vuln url: string; error?: string; } -interface Delta { - version: string; - date: string | null; - breaking: string[]; - deprecations: string[]; - features: string[]; - security: string[]; - peerEngine: string[]; - releaseNotes: string | null; - diffUrl: string | null; - changelogUrl: string | null; - source: "github-release" | "none"; - error: string | null; -} - interface Usage { importedSymbols: string[]; typeOnlySymbols: string[]; - sites: string[]; - callSites: string[]; + sites: string[]; // import file:line + callSites: string[]; // reference file:line (codemap only) source: "codemap" | "grep"; } @@ -66,13 +79,17 @@ interface Evidence { }; outdated: OutdatedPkg[]; audit: { - bunAudit: unknown; + 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 } = {}, @@ -101,6 +118,7 @@ async function run( 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 }> { @@ -118,7 +136,7 @@ async function runSoft( return { ok: code === 0, stdout, code }; } -// Pace GitHub calls to avoid its secondary burst limit. +// Pace `gh` calls to avoid GitHub's secondary rate limit (burst protection). let lastGhCall = 0; const GH_MIN_GAP_MS = 1000; async function ghGate() { @@ -127,30 +145,38 @@ async function ghGate() { lastGhCall = Date.now(); } +// ──────────────────────────────────────────────────────────────────────────── // Disk cache for gh release/advisory data — iterative runs don't re-fetch, -// avoiding repeated rate-limit failures. +// 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 +const CACHE_MAX_AGE_MS = 1000 * 60 * 60; // 1 hour — GHSA can change +const PMDIFF_CACHE_MS = 1000 * 60 * 60 * 24 * 7; // published tarball pair is immutable +const DIFF_CONCURRENCY = 4; function cachePath(key: string): string { return `${CACHE_DIR}/${key.replace(/[^a-z0-9._-]/gi, "_")}.json`; } -async function readCache(key: string): Promise { +async function readCache( + key: string, + maxAgeMs = CACHE_MAX_AGE_MS, +): Promise { const f = Bun.file(cachePath(key)); if (!(await f.exists())) return null; - if (Date.now() - f.lastModified > CACHE_MAX_AGE_MS) return null; + if (Date.now() - f.lastModified > maxAgeMs) return null; return await f.text(); } async function writeCache(key: string, data: string): Promise { try { + // Bun.write auto-creates parent directories. await Bun.write(cachePath(key), data); } catch { - // Evidence collection must continue if the cache is unwritable. + // cache is best-effort } } @@ -167,6 +193,10 @@ function stripBunHeader(s: string): string { 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); @@ -186,30 +216,40 @@ 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"; } -function semverInRange(version: string, range: string | null): boolean { +export function semverInRange(version: string, range: string | null): boolean { if (!range) return false; - // Covers GHSA comparator groups without adding `semver` to this dev script. + // 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) continue; + if (!m) { + groupOk = false; + break; + } + parsed += 1; const [, op, ver] = m; const c = cmpVer(version, ver); if (op === ">=" && !(c >= 0)) groupOk = false; @@ -218,32 +258,57 @@ function semverInRange(version: string, range: string | null): boolean { if (op === "<" && !(c < 0)) groupOk = false; if ((!op || op === "=") && c !== 0) groupOk = false; } - if (groupOk) return true; + if (parsed > 0 && groupOk) return true; } return false; } +export function ghsaVerdict( + inRange: boolean, + fixedIn: string | null, + targetVer: string, +): AdvisoryVuln["verdict"] { + if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) <= 0) { + return "priority-bump"; + } + if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) > 0) { + return "needs-higher-target"; + } + if (!inRange) return "cleared-at-current"; + return "unpatched"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Gatherers +// ──────────────────────────────────────────────────────────────────────────── + const HIGH_RISK = [ - "better-sqlite3", - "oxc-parser", - "oxc-resolver", - "lightningcss", - "zod", - "@modelcontextprotocol/sdk", - "chokidar", + "react", + "react-dom", + "vue", + "svelte", + "solid-js", + "preact", + "lit", + "alpinejs", + "@angular/core", + "vitest", + "jsdom", "tsdown", ]; function workspaceManifests(): string[] { const manifests = ["package.json"]; - try { - for (const entry of readdirSync("packages", { withFileTypes: true })) { - if (entry.isDirectory()) { - manifests.push(`packages/${entry.name}/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. } - } catch { - // The root manifest is sufficient in a single-package repository. } return manifests; } @@ -252,7 +317,6 @@ async function parsePackageJson(): Promise { const direct: Evidence["inventory"]["direct"] = []; const classify = (v: string): "exact" | "caret" | "tilde" => v.startsWith("^") ? "caret" : v.startsWith("~") ? "tilde" : "exact"; - // Internal workspace dependencies are not registry-upgradable. const seen = new Set(); const add = (name: string, version: string, dev: boolean) => { if (version.startsWith("workspace:")) return; @@ -278,10 +342,13 @@ async function parsePackageJson(): Promise { 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()); @@ -295,7 +362,7 @@ async function parsePackageJson(): Promise { const vers = [...versions]; const hasDirect = directNames.has(name); const majorSplit = new Set(vers.map((v) => parseVer(v)[0])).size > 1; - // Same-major transitive-only duplicates are intentionally ignored. + // Skill rule: flag only direct-dep conflicts or semver-major splits. if (hasDirect || majorSplit) { transitiveDuplicates.push({ pkg: name, @@ -306,13 +373,12 @@ async function parsePackageJson(): Promise { return { direct, transitiveDuplicates }; } -async function parseBunOutdated(): Promise { - // `--filter '*'` covers every workspace package (root-only `bun outdated` - // misses per-package deps). Adds a trailing `Workspace` column and appends - // ` (dev)`/` (peer)` to the package name — both handled below. - const { stdout } = await runSoft(["bun", "outdated", "--filter", "*"]); +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 @@ -322,8 +388,9 @@ async function parseBunOutdated(): Promise { if (cells.length < 4) continue; const [pkgRaw, current, , latest] = cells; if (!pkgRaw || pkgRaw === "Package" || pkgRaw.startsWith("---")) continue; - const pkg = pkgRaw.replace(/\s*\((?:dev|peer|optional)\)\s*$/, ""); 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); @@ -333,11 +400,28 @@ async function parseBunOutdated(): Promise { 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); @@ -351,6 +435,97 @@ async function runBunAudit(): Promise { } } +async function fetchGhsaList(pkg: string): Promise { + return cachedParsed( + `ghsa:${pkg}`, + CACHE_MAX_AGE_MS, + () => + run( + [ + "gh", + "api", + "-X", + "GET", + "/advisories", + "-f", + "ecosystem=npm", + "-f", + `affects=${pkg}`, + ], + { retries: 2 }, + ), + (raw) => { + const list = JSON.parse(raw); + if (!Array.isArray(list)) { + throw new Error( + `non-array response from gh api advisories: ${String(list).slice(0, 120)}`, + ); + } + return list; + }, + ); +} + +function advisoryFromGhsa( + a: { + ghsa_id: string; + cve_id?: string | null; + severity?: string; + vulnerabilities?: { + package?: { name?: string }; + vulnerable_version_range?: string | null; + first_patched_version?: { identifier?: string } | string | null; + }[]; + }, + pkg: string, + installed: Map, + target: Map, +): AdvisoryVuln { + const vuln = + a.vulnerabilities?.find((v) => v.package?.name === pkg) ?? + a.vulnerabilities?.[0] ?? + {}; + const range = vuln.vulnerable_version_range ?? null; + const patched = vuln.first_patched_version; + const fixedIn = + typeof patched === "string" ? patched : (patched?.identifier ?? null); + const installedVer = installed.get(pkg) ?? ""; + const targetVer = target.get(pkg) ?? ""; + const inRange = installedVer ? semverInRange(installedVer, range) : false; + return { + id: a.ghsa_id, + cveId: a.cve_id ?? null, + severity: a.severity ?? "unknown", + vulnerableRange: range, + fixedIn, + installedInRange: inRange, + verdict: ghsaVerdict(inRange, fixedIn, targetVer), + url: `https://github.com/advisories/${a.ghsa_id}`, + }; +} + +function ghsaCheckFailed( + pkg: string, + e: unknown, +): Evidence["audit"]["ghsa"][number] { + return { + pkg, + advisories: [ + { + id: "error", + cveId: null, + severity: "unknown", + vulnerableRange: null, + fixedIn: null, + installedInRange: false, + verdict: "check-failed", + url: "", + error: e instanceof Error ? e.message : String(e), + }, + ], + }; +} + async function ghsaSpotCheck( pkgs: string[], installed: Map, @@ -359,297 +534,124 @@ async function ghsaSpotCheck( const out: Evidence["audit"]["ghsa"] = []; for (const pkg of pkgs) { try { - const cacheKey = `ghsa:${pkg}`; - const cached = await readCache(cacheKey); - const raw = - cached ?? - (await run( - [ - "gh", - "api", - "-X", - "GET", - "/advisories", - "-f", - "ecosystem=npm", - "-f", - `affects=${pkg}`, - ], - { retries: 2 }, - )); - const list = JSON.parse(raw); - if (!Array.isArray(list)) { - throw new Error( - `non-array response from gh api advisories: ${String(list).slice(0, 120)}`, - ); - } - if (!cached) await writeCache(cacheKey, JSON.stringify(list)); - const advisories: AdvisoryVuln[] = []; - for (const a of list) { - const vuln = - a.vulnerabilities?.find((v: any) => v.package?.name === pkg) ?? - a.vulnerabilities?.[0] ?? - {}; - const range: string | null = vuln.vulnerable_version_range ?? null; - const fixedIn: string | null = - vuln.first_patched_version?.identifier ?? - vuln.first_patched_version ?? - null; - const installedVer = installed.get(pkg) ?? ""; - const targetVer = target.get(pkg) ?? ""; - const inRange = installedVer - ? semverInRange(installedVer, range) - : false; - let verdict: AdvisoryVuln["verdict"] = "unpatched"; - if (inRange && fixedIn && targetVer && cmpVer(fixedIn, targetVer) <= 0) - verdict = "priority-bump"; - else if ( - inRange && - fixedIn && - targetVer && - cmpVer(fixedIn, targetVer) > 0 - ) - verdict = "needs-higher-target"; - else if (!inRange) verdict = "cleared-at-current"; - advisories.push({ - id: a.ghsa_id, - cveId: a.cve_id ?? null, - severity: a.severity ?? "unknown", - vulnerableRange: range, - fixedIn, - installedInRange: inRange, - verdict, - url: `https://github.com/advisories/${a.ghsa_id}`, - }); - } - out.push({ pkg, advisories }); - } catch (e) { + const list = await fetchGhsaList(pkg); out.push({ pkg, - advisories: [ - { - id: "error", - cveId: null, - severity: "unknown", - vulnerableRange: null, - fixedIn: null, - installedInRange: false, - verdict: "check-failed", - url: "", - error: e instanceof Error ? e.message : String(e), - }, - ], + advisories: list.map((a) => + advisoryFromGhsa( + a as Parameters[0], + pkg, + installed, + target, + ), + ), }); + } catch (e) { + out.push(ghsaCheckFailed(pkg, e)); } } return out; } -async function getRepoSlug( - pkg: string, -): Promise<{ owner: string; repo: string } | null> { - try { - const raw = stripBunHeader( - await run(["bun", "pm", "view", pkg, "repository", "--json"], { - retries: 2, - }), - ); - const data = JSON.parse(raw); - const url: string = data.url ?? ""; - // Preserve dots in repo names (e.g. mozilla/pdf.js); strip a trailing .git. - const m = url.match( - /github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/, - ); - return m ? { owner: m[1], repo: m[2] } : null; - } catch { - return null; +async function cachedParsed( + key: string, + maxAgeMs: number, + fetchText: () => Promise, + parse: (raw: string) => T, +): Promise { + const cached = await readCache(key, maxAgeMs); + if (cached) return parse(cached); + const raw = await fetchText(); + const value = parse(raw); + await writeCache(key, raw); + return value; +} + +function parseDiffJson(raw: string, label: string): BunPmDiffJson { + const body = stripBunHeader(raw); + const data: unknown = JSON.parse(body); + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error(`${label}: expected a JSON object`); } -} - -async function getVersions(pkg: string): Promise { - try { - const raw = stripBunHeader( - await run(["bun", "pm", "view", pkg, "versions", "--json"], { - retries: 2, - }), - ); - const list = JSON.parse(raw); - return Array.isArray(list) ? list : []; - } catch { - return []; + const obj = data as Record; + if ("files" in obj && !Array.isArray(obj.files)) { + throw new Error(`${label}: files must be an array`); } + if ("notes" in obj && !Array.isArray(obj.notes)) { + throw new Error(`${label}: notes must be an array`); + } + return data as BunPmDiffJson; } +/** One current→target tarball span via `bun pm diff`. Stat first, then patches for keep-paths. */ async function gatherDeltas( pkg: string, current: string, target: string, + importedSymbols: string[], ): Promise { - if (isPrerelease(target) || isPrerelease(current)) { - return [ - { - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: - "prerelease/moving-target build — no per-version changelog; gate on same-major-line only", - }, - ]; - } - const slug = await getRepoSlug(pkg); - if (!slug) { + const changelogUrl = npmVersionUrl(pkg, target); + try { + const spec = `${pkg}@${current}`; + const range = `${pkg}@${current}..${target}`; + const stat = await cachedParsed( + `pmdiff-stat:${range}`, + PMDIFF_CACHE_MS, + () => + run(["bun", "pm", "diff", spec, target, "--json", "--stat"], { + retries: 1, + }), + (raw) => parseDiffJson(raw, `bun pm diff --stat ${range}`), + ); + const patchPaths = selectPatchPaths(stat.files ?? [], importedSymbols); + let patches: BunPmDiffJson | null = null; + if (patchPaths.length > 0) { + const patchKey = `pmdiff-patch:${range}:${Bun.hash(patchPaths.join("\0")).toString(16)}`; + patches = await cachedParsed( + patchKey, + PMDIFF_CACHE_MS, + () => + run(["bun", "pm", "diff", spec, target, "--json", ...patchPaths], { + retries: 1, + }), + (raw) => parseDiffJson(raw, `bun pm diff patches ${range}`), + ); + } + return [buildDelta({ target, changelogUrl, stat, patches })]; + } catch (e) { return [ - { - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: "no github repository found", - }, - ]; - } - const { owner, repo } = slug; - const allVersions = await getVersions(pkg); - const inRange = allVersions - .filter( - (v) => - !isPrerelease(v) && cmpVer(v, current) > 0 && cmpVer(v, target) <= 0, - ) - .sort((a, b) => cmpVer(a, b)); - - // Resolve actual tags once because repositories use incompatible tag formats. - const releases = await fetchReleaseMap(owner, repo); - const tagOf = (v: string): string | null => releases.get(v)?.tagName ?? null; - - const deltas: Delta[] = []; - let prevTag = tagOf(current) ?? `v${current}`; - for (const v of inRange) { - const rel = tagOf(v) ? releases.get(v) : null; - const tag = rel?.tagName ?? null; - const diffUrl = tag - ? `https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}` - : null; - const body = rel?.body ?? null; - const date = rel?.publishedAt ?? null; - const source: Delta["source"] = rel ? "github-release" : "none"; - const error = rel - ? null - : `no github release for ${pkg}@${v} (${releases.size} releases scanned — likely a gh secondary rate-limit or monorepo squashed release; deep-dive via changelogUrl)`; - // Preserve a source link even when no version-specific release was found. - const changelogUrl = tag - ? `https://github.com/${owner}/${repo}/releases/tag/${tag}` - : `https://github.com/${owner}/${repo}/releases`; - deltas.push({ - version: v, - date, - breaking: extractLines(body, /breaking|breaking change/i), - deprecations: extractLines(body, /deprecat|removed export/i), - features: extractLines(body, /^feat|feature|^add|^new/i), - security: extractLines( - body, - /security|cve|prototype pollution|vulnerabilit/i, - ), - peerEngine: extractLines( - body, - /peer dep|engine|requires (node|bun|react)/i, + failedDelta( + target, + changelogUrl, + e instanceof Error ? e.message : String(e), ), - releaseNotes: body, - diffUrl, - changelogUrl, - source, - error, - }); - if (tag) prevTag = tag; - } - if (deltas.length === 0) { - deltas.push({ - version: target, - date: null, - breaking: [], - deprecations: [], - features: [], - security: [], - peerEngine: [], - releaseNotes: null, - diffUrl: null, - changelogUrl: null, - source: "none", - error: "no versions found in range", - }); + ]; } - return deltas; } -async function fetchReleaseMap( - owner: string, - repo: string, -): Promise< - Map< - string, - { tagName: string; publishedAt: string | null; body: string | null } - > -> { - const map = new Map< - string, - { tagName: string; publishedAt: string | null; body: string | null } - >(); - const cacheKey = `releases:${owner}/${repo}`; - try { - const cached = await readCache(cacheKey); - const raw = - cached ?? - (await run([ - "gh", - "api", - `repos/${owner}/${repo}/releases?per_page=100`, - ])); - const list = JSON.parse(raw); - if (!Array.isArray(list)) { - // gh returns a JSON object (e.g. {"message":"secondary rate limit"}) on rate-limit — not an array. - throw new Error( - `non-array response from gh api releases: ${String(raw).slice(0, 120)}`, - ); +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]!); } - if (!cached) await writeCache(cacheKey, JSON.stringify(list)); - for (const r of list) { - const m = (r.tag_name as string).match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/); - if (m) - map.set(m[1], { - tagName: r.tag_name, - publishedAt: r.published_at ?? null, - body: r.body ? String(r.body).slice(0, 1200) : null, - }); - } - } catch { - // repo has no releases or gh failed (secondary rate-limit / not found) — empty map; caller records error per version. } - return map; + const n = Math.min(concurrency, items.length); + if (n === 0) return out; + await Promise.all(Array.from({ length: n }, () => worker())); + return out; } -function extractLines(body: string | null, re: RegExp): string[] { - if (!body) return []; - return body - .split("\n") - .map((l) => l.trim()) - .filter((l) => re.test(l) && l.length > 0) - .slice(0, 6) - .map((l) => l.replace(/^[#*\-\s]+/, "").slice(0, 160)); -} +// ──────────────────────────────────────────────────────────────────────────── +// Codemap (parsed imports + call sites) — primary usage source, grep fallback +// ──────────────────────────────────────────────────────────────────────────── let codemapAvailable: boolean | null = null; @@ -687,20 +689,27 @@ function parseSpecifiers(raw: string): string[] { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.filter(Boolean); - } catch {} + } catch { + // fall through + } return String(raw) .split(",") .map((s) => s.trim()) .filter(Boolean); } -// Batch imports and references across all packages to avoid per-package queries, -// then scope references to each package's importing files and symbols. +/** + * 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; + 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)}/%'`) @@ -709,6 +718,7 @@ async function gatherAllUsage(pkgs: string[]): Promise> { `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, { @@ -739,6 +749,7 @@ async function gatherAllUsage(pkgs: string[]): Promise> { } } + // 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()) { @@ -780,6 +791,7 @@ async function gatherAllUsage(pkgs: string[]): Promise> { } 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", @@ -811,12 +823,14 @@ async function grepUsage(pkg: string): Promise { }; } +/** 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)); } @@ -828,6 +842,10 @@ async function gatherAllUsageWithFallback( } } +// ──────────────────────────────────────────────────────────────────────────── +// Main +// ──────────────────────────────────────────────────────────────────────────── + async function main() { const args = process.argv.slice(2); const onlyIdx = args.indexOf("--only"); @@ -846,27 +864,39 @@ async function main() { if (onlyPkg) outdated = outdated.filter((o) => o.pkg === onlyPkg); const target = new Map(outdated.map((o) => [o.pkg, o.latest])); - console.error("→ bun audit"); - const bunAudit = await runBunAudit(); - - console.error("→ ghsa spot-check"); + console.error("→ bun audit + ghsa"); const ghsaPkgs = (onlyPkg ? [onlyPkg] : HIGH_RISK).filter( (p) => installed.has(p) || target.has(p), ); - const ghsa = await ghsaSpotCheck(ghsaPkgs, installed, target); - - console.error("→ deltas"); - const deltas: Record = {}; - for (const o of outdated) { - console.error(` ${o.pkg} ${o.current} → ${o.latest}`); - deltas[o.pkg] = await gatherDeltas(o.pkg, o.current, o.latest); - } + const [bunAudit, ghsa] = await Promise.all([ + runBunAudit(), + ghsaSpotCheck(ghsaPkgs, installed, target), + ]); console.error("→ usage (batched codemap)"); const usageMap = await gatherAllUsageWithFallback(outdated.map((o) => o.pkg)); const usage: Record = {}; - for (const o of outdated) - usage[o.pkg] = usageMap.get(o.pkg) ?? (await grepUsage(o.pkg)); + for (const o of outdated) { + const u = usageMap.get(o.pkg); + if (!u) throw new Error(`usage missing for ${o.pkg}`); + usage[o.pkg] = u; + } + + console.error(`→ deltas (bun pm diff, ${DIFF_CONCURRENCY} at a time)`); + const deltaEntries = await mapPool(outdated, DIFF_CONCURRENCY, async (o) => { + console.error(` ${o.pkg} ${o.current} → ${o.latest}`); + const u = usage[o.pkg]; + const symbols = [ + ...(u?.importedSymbols ?? []), + ...(u?.typeOnlySymbols ?? []), + ]; + return [ + o.pkg, + await gatherDeltas(o.pkg, o.current, o.latest, symbols), + ] as const; + }); + const deltas: Record = {}; + for (const [pkg, list] of deltaEntries) deltas[pkg] = list; const evidence: Evidence = { generatedAt: new Date().toISOString(), @@ -886,7 +916,9 @@ async function main() { } } -main().catch((e) => { - console.error("fatal:", e); - process.exit(1); -}); +if (import.meta.main) { + main().catch((e) => { + console.error("fatal:", e); + process.exit(1); + }); +} diff --git a/scripts/upgrade-packages/tarball-delta.test.ts b/scripts/upgrade-packages/tarball-delta.test.ts new file mode 100644 index 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, + }; +}