From 210ddf3bf4760af37548354749f74f2b9ebd4f5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:53:27 +0000 Subject: [PATCH] Track oxc changes up to the latest release tags The oxc-peer-tracker skill now compares the peerDependencies floor against the newest oxlint/oxfmt releases tagged in oxc-project/oxc instead of our devDependencies, so the tracking issue covers releases before Renovate brings them in. scripts/oxc-version-diff.ts lists the tags with `git ls-remote`, takes the newest one published on npm as the upper bound and links each release in range. The skill also runs the PR CI steps against the latest releases in a throwaway worktree to flag failures early. With that in place, drop the Renovate rule that updated oxc tooling as soon as releases passed minimumReleaseAge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Em5Kt9FdzYzHk2YX2vmo7N --- .claude/skills/oxc-peer-tracker/SKILL.md | 45 +++-- .../references/issue-template.md | 24 ++- .github/renovate.json | 9 +- scripts/oxc-version-diff.ts | 188 ++++++++++++++++-- 4 files changed, 211 insertions(+), 55 deletions(-) diff --git a/.claude/skills/oxc-peer-tracker/SKILL.md b/.claude/skills/oxc-peer-tracker/SKILL.md index fd0af6b..fe1306a 100644 --- a/.claude/skills/oxc-peer-tracker/SKILL.md +++ b/.claude/skills/oxc-peer-tracker/SKILL.md @@ -1,13 +1,13 @@ --- name: oxc-peer-tracker -description: Create or refresh the GitHub issue that tracks oxlint and oxfmt changes between this package's peerDependencies floor and its devDependencies (new format settings, new lint rules, rule option/category/fix changes, and lint/format behavior changes). Use when the oxlint or oxfmt version changes in package.json devDependencies or peerDependencies, when asked to update or refresh the oxlint/oxfmt tracking issue, or before bumping the oxlint/oxfmt peer ranges. +description: Create or refresh the GitHub issue that tracks oxlint and oxfmt changes between this package's peerDependencies floor and the latest oxlint and oxfmt releases tagged in oxc-project/oxc (new format settings, new lint rules, rule option/category/fix changes, lint/format behavior changes, and whether the latest releases would fail our CI). Use when a new oxlint or oxfmt release comes out, when the oxlint or oxfmt version changes in package.json peerDependencies or devDependencies, when asked to update or refresh the oxlint/oxfmt tracking issue, or before bumping the oxlint/oxfmt peer ranges. --- # oxlint/oxfmt peer-bump tracker -This repository publishes shared oxlint and oxfmt configs. Renovate keeps `devDependencies` on the latest oxlint and oxfmt, while `peerDependencies` stays on an older floor until we're ready to raise it. A single GitHub issue lists everything that changed between the two, as categorized checklists, so we know what to implement or address before raising the peer ranges. This skill creates that issue or refreshes it. +This repository publishes shared oxlint and oxfmt configs. `peerDependencies` stays on an older floor until we're ready to raise it, Renovate moves `devDependencies` up to new oxlint and oxfmt releases on its usual schedule, and oxc ships new releases in between. A single GitHub issue lists everything that changed from the peer floor up to the latest release, as categorized checklists, so we know what to implement or address before raising the peer ranges, and which upcoming changes would fail CI before Renovate opens a pull request for them. This skill creates that issue or refreshes it. -The issue always compares the **current `peerDependencies` floor** (the lowest version the range allows) against the **current `devDependencies` version**, covering every release in the half-open range `(peer floor, dev version]`. +The issue always compares the **current `peerDependencies` floor** (the lowest version the range allows) against the **latest release tagged in `oxc-project/oxc`** (the newest `oxlint_v` and `oxfmt_v` tags that are also published on npm), covering every release in the half-open range `(peer floor, latest release]`. The `devDependencies` versions don't bound the range; they only show which of those releases our CI already runs. ## Running unattended @@ -15,16 +15,16 @@ This skill also runs as a scheduled Claude Code Routine in a cloud session, with - The session may start on a `claude/…` branch instead of `main`. That's fine if `git diff origin/main` is empty; otherwise run `git checkout --detach origin/main` first. - Don't stop to ask questions. Where a step would need a decision, take the conservative option (for example, leave the issue open, or keep an existing item as it is) and mention it in the final summary. -- The only output is the issue body. Don't edit files in the repository, commit, push or open a pull request. `pnpm build` writes to `dist`, which is ignored. +- The only output is the issue body. Don't edit files in the repository, commit, push or open a pull request. `pnpm build` writes to `dist`, which is ignored. The CI preview in step 4 runs in a throwaway `git worktree` in the scratchpad directory, which is fine; remove it when you're done. - Use the GitHub MCP tools for issues in `bachman-dev/oxc-config`. They can't read `oxc-project/oxc` because it isn't in the session's repository scope, and `gh` may not be signed in. Read oxc releases and source as steps 3 and 4 describe. - The skill needs `registry.npmjs.org`, `raw.githubusercontent.com`, `git` access to `github.com` and WebFetch access to `github.com` pages, all of which the default network policy allows. `oxc.rs` isn't on the default list. This repository's environment allows it, but if it's blocked, use the source fallback in step 4. - If a source can't be reached, carry on with the others and name the gap in the issue's baseline line and in the final summary. Don't fill it in from memory. ## 1. Find the tracking issue -List the open issues in `bachman-dev/oxc-config` (GitHub MCP `list_issues` with `state: "OPEN"`) and pick the one whose body contains the marker ``. Don't use issue search for this, because GitHub search doesn't index HTML comments. If none has it, fall back to the title "Track oxlint/oxfmt changes between peerDependencies and devDependencies". Read the full current body, since checked boxes and human notes must survive the refresh (see step 5). If no issue exists, create one with that title in step 7. +List the open issues in `bachman-dev/oxc-config` (GitHub MCP `list_issues` with `state: "OPEN"`) and pick the one whose body contains the marker ``. Don't use issue search for this, because GitHub search doesn't index HTML comments. If none has it, fall back to the title "Track oxlint/oxfmt changes between peerDependencies and the latest releases", or the older "Track oxlint/oxfmt changes between peerDependencies and devDependencies". Read the full current body, since checked boxes and human notes must survive the refresh (see step 5). If no issue exists, create one with that title in step 7. -If the issue's version table already matches the `peerDependencies` ranges and `devDependencies` versions in `package.json` for both tools, the release range hasn't changed, so the items can't have either. Skip steps 3 and 4. Run step 2 and the baseline commands from step 4, then update only "Last updated" and the baseline line. Leave every item as it is. +After step 2, compare the issue's version table with the report's. If the `peerDependencies` ranges and latest releases match for both tools, the release range hasn't changed, so the items can't have either. Skip steps 3 and 4 except for the baseline and CI preview in step 4, then update only "Last updated", the `devDependencies` column, the "(not in devDependencies yet)" markers in the releases column, the baseline and the CI preview. If the CI preview turns up a new failure, add it as step 4 describes. Leave every item as it is. ## 2. Gather the mechanical diff @@ -34,8 +34,10 @@ pnpm build node ./scripts/oxc-version-diff.ts > "${TMPDIR:-/tmp}/oxc-version-diff.md" ``` -If the session has a scratchpad directory, write the report there instead. The script reads both versions from `package.json` and installs the peer-floor oxlint and oxfmt into a temporary directory. It prints a Markdown report with these sections: +If the session has a scratchpad directory, write the report there instead. The script reads the peer floor and devDependency versions from `package.json`, lists the release tags in `oxc-project/oxc` with `git ls-remote` and takes the newest tagged version that's on npm as the latest release. It installs the peer-floor and latest oxlint and oxfmt into a temporary directory and prints a Markdown report with these sections: +- **Version table**: the `peerDependencies` range and floor, the `devDependencies` version and the latest release for each tool. Notes below the table say when a tag isn't on npm yet (that release is left out until it is) or when the tags couldn't be listed (the upper bound falls back to npm's `latest` dist-tag). +- **Releases in range**: every version in range with its GitHub release tag and link, marked "(not in devDependencies yet)" when our CI doesn't run it yet. Use these for the issue's version table and for step 3. - **oxlint rule catalog changes**: added or removed rules, and changes to category, fix type (for example `fixable_fix` → `fixable_suggestion`), type-awareness and on-by-default status. Release notes often omit fix-type changes, so this section is the only reliable source for them. - **oxlint / oxfmt configuration schema changes**: new or changed rule options and new or changed format settings. If the oxfmt section says "No changes", there are no new format settings. - **Status of affected rules in our config**: for each rule in the catalog diff or the changelogs, whether we configure it (with severity and overrides), turn it on through the `correctness` category, or leave it off. Use this to mark items **(we enable)**; don't guess. @@ -52,12 +54,10 @@ If the script can't reach `raw.githubusercontent.com`, it still prints the other ## 3. Read the GitHub release notes -The changelogs are **not** complete. Some fixes appear only in the GitHub release notes. For every version in range, read the release page at `https://github.com/oxc-project/oxc/releases/tag/`: +The changelogs are **not** complete. Some fixes appear only in the GitHub release notes. For every version in range, read the release page linked in the script's "Releases in range" section: -- When oxlint and oxfmt shipped together, the combined tag is `apps_v`, for example `apps_v1.84.0` holds oxlint 1.84.0 and oxfmt 0.69.0. -- Otherwise the tags are `oxlint_v` and `oxfmt_v`. -- If a tag 404s, try the other form, or find the tag on `https://github.com/oxc-project/oxc/releases`. -- To skip the guessing, list the real tags first: `git ls-remote --tags https://github.com/oxc-project/oxc | grep -E 'apps_v|oxlint_v|oxfmt_v'`. +- When oxlint and oxfmt shipped together, the release is on the combined `apps_v` tag, for example `apps_v1.84.0` holds oxlint 1.84.0 and oxfmt 0.69.0. Otherwise it's on `oxlint_v` or `oxfmt_v`. The script already picks the right one. +- If the script couldn't list the tags, list them yourself with `git ls-remote --tags https://github.com/oxc-project/oxc | grep -E 'apps_v|oxlint_v|oxfmt_v'`, or find them on `https://github.com/oxc-project/oxc/releases`. If a tag 404s, try the other form. Fetch each page with WebFetch. WebFetch summarizes pages, so asking for the notes verbatim gets a condensed summary that drops entries. Ask for one line per entry instead, for example: "List every entry in the release notes body, one per line, as `
| | | | `. Include every entry from every section, and don't skip or merge entries. At the end, give the total number of entries." Cite only PR numbers that appear in that list or in the script's changelog section. Any rule that appears in the release notes but not in the script's status section gets a re-run of the script with that rule as an argument. @@ -67,6 +67,23 @@ Fetch each page with WebFetch. WebFetch summarizes pages, so asking for the note - **Rules we enable:** compare the change against our settings in `src/oxlint/rules/*.ts`, including options, overrides, admonishments and `changes` notes. Say concretely how it affects us, for example "this affects our `destructuredArrayIgnorePattern: "^_"`". - **Formatting changes:** relate them to our settings in `src/oxfmt/settings.ts`, for example `experimentalOperatorPosition`, `jsdoc` sub-options and `proseWrap`. - **Baseline:** run `pnpm test`, `pnpm lint` and `pnpm format:check` against the installed devDependencies and record the results. +- **CI preview:** if the latest release of either tool is newer than our devDependencies, run the CI steps from `.github/workflows/pr.yml` against the latest releases in a throwaway worktree, so we learn about failures before Renovate's pull request does. Use the latest versions from the script's version table: + + ```sh + preview="${TMPDIR:-/tmp}/oxc-latest-preview" + git worktree add --detach "$preview" HEAD + cd "$preview" + pnpm add --save-dev --save-exact oxlint@ oxfmt@ oxlint-tsgolint@latest + pnpm build + pnpm test + pnpm typecheck + pnpm build:docs && git status --porcelain -- src + pnpm format:check + pnpm lint + cd - && git worktree remove --force "$preview" + ``` + + Put the worktree in the scratchpad directory if there is one. pnpm may add the new versions to `minimumReleaseAgeExclude` in the worktree's `pnpm-workspace.yaml`; that's expected and is thrown away with the worktree. Run every step even when an earlier one fails, and record each result. Any `src/oxlint/README.md` / `src/oxfmt/README.md` changes from `pnpm build:docs` mean the regenerated docs would differ. For each failure, add or update the item it belongs to (for example, a rule option we set that the latest oxlint rejects) and say in it that it fails CI with the latest release. If both latest releases equal our devDependencies, skip this and say the preview matches the baseline. ## 5. Merge with the existing issue @@ -76,7 +93,7 @@ Fetch each page with WebFetch. WebFetch summarizes pages, so asking for the note - If the item for a rule or setting changed again in a newer release, update that one item. Don't add a duplicate. - Keep "Last updated" set to today's date. -If the peer floor equals the dev version for both tools, nothing is pending. Replace the checklists with a short "Nothing pending: peerDependencies match devDependencies" note and keep the marker, the version table and the refresh instructions. Leave the issue open unless the user asks to close it. +If the peer floor equals the latest release for both tools, nothing is pending. Replace the checklists with a short "Nothing pending: peerDependencies match the latest releases" note and keep the marker, the version table and the refresh instructions. Leave the issue open unless the user asks to close it. ## 6. Write the body @@ -92,4 +109,4 @@ Follow `references/issue-template.md` exactly. It has the section order, the cat ## 7. Publish -Update the issue body in place, or create the issue if none exists. Use the GitHub MCP `issue_write` tool (`method: "update"`) or `gh issue edit --body-file `. Don't post a comment for each refresh; the body is the record. Afterwards, tell the user what changed since the previous version of the body: items added, items dropped because the peer floor moved, and anything that newly needs a decision. When running unattended, put this summary in your final message, together with a link to the issue and any source you couldn't reach. Say "No changes" if only the date and baseline moved. +Update the issue body in place, or create the issue if none exists. Use the GitHub MCP `issue_write` tool (`method: "update"`) or `gh issue edit --body-file `. If the issue still has the older title, change it to the template's title in the same update. Don't post a comment for each refresh; the body is the record. Afterwards, tell the user what changed since the previous version of the body: items added, items dropped because the peer floor moved, anything that newly needs a decision, and any CI preview failures. When running unattended, put this summary in your final message, together with a link to the issue and any source you couldn't reach. Say "No changes" if only the date and baseline moved. diff --git a/.claude/skills/oxc-peer-tracker/references/issue-template.md b/.claude/skills/oxc-peer-tracker/references/issue-template.md index c07332a..2596bfe 100644 --- a/.claude/skills/oxc-peer-tracker/references/issue-template.md +++ b/.claude/skills/oxc-peer-tracker/references/issue-template.md @@ -1,6 +1,6 @@ # Tracking issue template -**Title:** `Track oxlint/oxfmt changes between peerDependencies and devDependencies` +**Title:** `Track oxlint/oxfmt changes between peerDependencies and the latest releases` Copy the body below and fill in the `{placeholders}`. Keep every heading, in this order, even when a category is empty. For an empty category write `- [x] **None.** {how you know, for example "the rule catalog diff shows no added rules"}`. @@ -19,23 +19,23 @@ Category definitions: | Formatting behavior changes | Split by subject (Markdown, JSDoc, comments and JS/TS layout, CSS/YAML/GraphQL and so on); relate each item to our settings in `src/oxfmt/settings.ts` | | Bundled Prettier and CLI behavior | Prettier version bumps, config discovery and CLI changes | -Release link per version: the combined `apps_v` tag when oxlint and oxfmt shipped together, otherwise `oxlint_v` / `oxfmt_v`. +Release link per version: use the tags and links from the script's "Releases in range" section. They're the combined `apps_v` tag when oxlint and oxfmt shipped together, otherwise `oxlint_v` / `oxfmt_v`. Mark each release that's newer than our `devDependencies` with "(not in devDependencies yet)". ```markdown -This issue tracks the oxlint and oxfmt changes between the minimum versions in our `peerDependencies` and the versions in our `devDependencies`. We work through these items before raising the peer ranges. Whenever this issue is refreshed, it compares the current `peerDependencies` floor against the current `devDependencies`. +This issue tracks the oxlint and oxfmt changes between the minimum versions in our `peerDependencies` and the latest oxlint and oxfmt releases tagged in `oxc-project/oxc`. We work through these items before raising the peer ranges. Because it covers releases that Renovate hasn't brought into our `devDependencies` yet, it also shows upcoming changes that would fail CI. Whenever this issue is refreshed, it compares the current `peerDependencies` floor against the latest releases. -To refresh it, ask Claude to use the `oxc-peer-tracker` skill (`.claude/skills/oxc-peer-tracker/SKILL.md`). Do this whenever the oxlint or oxfmt dev or peer versions change, and before raising the peer ranges. +To refresh it, ask Claude to use the `oxc-peer-tracker` skill (`.claude/skills/oxc-peer-tracker/SKILL.md`). A scheduled Claude Code Routine also refreshes it weekly. Refresh it by hand when a new oxlint or oxfmt release comes out, when the peer ranges change, and before raising the peer ranges. **Last updated:** {YYYY-MM-DD} -| Package | `peerDependencies` | `devDependencies` | Releases covered | -| ------- | ------------------ | ----------------- | ---------------- | -| oxlint | `{range}` | `{version}` | {release links} | -| oxfmt | `{range}` | `{version}` | {release links} | +| Package | `peerDependencies` | `devDependencies` | Latest release | Releases covered | +| ------- | ------------------ | ----------------- | -------------- | ---------------- | +| oxlint | `{range}` | `{version}` | `{version}` | {release links} | +| oxfmt | `{range}` | `{version}` | `{version}` | {release links} | -Sources: the GitHub release notes above, plus the `apps/oxlint`, `apps/oxfmt`, `crates/oxc_linter` and `crates/oxc_formatter*` changelogs in `oxc-project/oxc`, and `scripts/oxc-version-diff.ts`. The script compares the rule catalog (`oxlint --rules`) and both packages' `configuration_schema.json` between the peer-floor and installed versions. +Sources: the GitHub release notes above, plus the `apps/oxlint`, `apps/oxfmt`, `crates/oxc_linter` and `crates/oxc_formatter*` changelogs in `oxc-project/oxc`, and `scripts/oxc-version-diff.ts`. The script compares the rule catalog (`oxlint --rules`) and both packages' `configuration_schema.json` between the peer-floor and latest versions. Items are marked **(we enable)** when the rule is turned on by our config, either explicitly in `src/oxlint/rules/*.ts` or through the `correctness` category. @@ -79,11 +79,13 @@ Items are marked **(we enable)** when the rule is turned on by our config, eithe ## Before bumping the peer ranges - [ ] Address or consciously skip every item above. -- [ ] Bump `peerDependencies` to `"oxlint": "^{dev version}"` and `"oxfmt": ">={dev version} <1"`. Use the current devDependency versions if they've moved on. +- [ ] Bump `peerDependencies` to `"oxlint": "^{dev version}"` and `"oxfmt": ">={dev version} <1"`, using the current `devDependencies` versions. The peer floor can't go past what CI runs, so items for releases newer than our `devDependencies` stay in this issue until Renovate brings them in. - [ ] Run `pnpm all` and check that `src/oxfmt/README.md` / `src/oxlint/README.md` regenerate cleanly. - [ ] Bump the package version and note the new minimum versions in the release notes. -**Baseline as of this update:** {results of `pnpm test`, `pnpm lint`, `pnpm format:check`, and the script's peer-floor oxfmt check} +**Baseline as of this update:** {results of `pnpm test`, `pnpm lint`, `pnpm format:check` with our `devDependencies`, and the script's peer-floor oxfmt check} + +**CI preview with the latest releases:** {results of `pnpm test`, `pnpm typecheck`, `pnpm build:docs`, `pnpm format:check` and `pnpm lint` with the latest oxlint and oxfmt, or "Same as the baseline: our `devDependencies` are the latest releases."} --- diff --git a/.github/renovate.json b/.github/renovate.json index 0953138..0ccb229 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,11 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["github>bachman-dev/renovate-config:jslib", "github>bachman-dev/renovate-config:oxlint-tsgolint"], - "packageRules": [ - { - "description": "Update oxc tooling as soon as releases pass minimumReleaseAge instead of waiting for the monthly schedule", - "matchPackageNames": ["oxfmt", "oxlint", "oxlint-tsgolint"], - "schedule": ["* * * * *"] - } - ] + "extends": ["github>bachman-dev/renovate-config:jslib", "github>bachman-dev/renovate-config:oxlint-tsgolint"] } diff --git a/scripts/oxc-version-diff.ts b/scripts/oxc-version-diff.ts index f05e555..b9b9217 100644 --- a/scripts/oxc-version-diff.ts +++ b/scripts/oxc-version-diff.ts @@ -7,8 +7,10 @@ import { parseArgs } from "node:util"; import bachmanDevConfig from "../src/oxlint/index.ts"; -// Reports what changed in oxlint and oxfmt between the floor of our peerDependencies ranges and the versions in our -// devDependencies, as raw material for the peer-bump tracking issue (see .claude/skills/oxc-peer-tracker/SKILL.md). +// Reports what changed in oxlint and oxfmt between the floor of our peerDependencies ranges and the latest releases +// tagged in oxc-project/oxc, as raw material for the peer-bump tracking issue (see +// .claude/skills/oxc-peer-tracker/SKILL.md). Using the latest tags rather than our devDependencies as the upper bound +// shows what's coming before Renovate opens a pull request for it. // // Usage: node ./scripts/oxc-version-diff.ts [rule ...] // @@ -29,6 +31,18 @@ interface Versions { peerFloor: string; peerRange: string; dev: string; + latest: string; +} + +interface Release { + tag: string; + version: string; +} + +interface ToolReleases { + latest: string; + notes: string[]; + releases: Release[]; } type JsonRecord = Record; @@ -39,8 +53,17 @@ type Tool = (typeof TOOLS)[number]; const CATALOG_FIELDS = ["category", "fix", "type_aware", "default"] as const satisfies (keyof CatalogEntry)[]; +const OXC_REPOSITORY_URL = "https://github.com/oxc-project/oxc"; + const CHANGELOG_BASE_URL = "https://raw.githubusercontent.com/oxc-project/oxc/main/"; +const NPM_REGISTRY_URL = "https://registry.npmjs.org/"; + +// Every release gets an `oxlint_v` or `oxfmt_v` tag. When both tools shipped from the same commit, +// that commit is also tagged `apps_v`, and the GitHub release lives on the `apps_v` tag. +const RELEASE_TAG = + /^(?[0-9a-f]+)\trefs\/tags\/(?apps|oxlint|oxfmt)_v(?\d+\.\d+\.\d+)(?:\^\{\})?$/v; + // Changelogs whose version headings follow each tool's own version numbers const CHANGELOGS: Record = { oxlint: ["apps/oxlint/CHANGELOG.md", "crates/oxc_linter/CHANGELOG.md"], @@ -108,30 +131,136 @@ function compareVersions(first: string, second: string): number { } function isInRange(version: string, versions: Versions): boolean { - return compareVersions(version, versions.peerFloor) > 0 && compareVersions(version, versions.dev) <= 0; + return compareVersions(version, versions.peerFloor) > 0 && compareVersions(version, versions.latest) <= 0; +} + +function readTaggedReleases(): Record { + const listing = execFileSync("git", ["ls-remote", "--tags", OXC_REPOSITORY_URL], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + // An annotated tag is listed twice, and the second (peeled `^{}`) line names the commit it points at + const commits = new Map(); + for (const line of listing.split("\n")) { + const { commit, prefix, version } = RELEASE_TAG.exec(line)?.groups ?? {}; + if (typeof commit === "string" && typeof prefix === "string" && typeof version === "string") { + commits.set(`${prefix}_v${version}`, commit); + } + } + const appsTags = new Map(); + for (const [tag, commit] of commits) { + if (tag.startsWith("apps_v")) { + appsTags.set(commit, tag); + } + } + const releases: Record = { oxlint: [], oxfmt: [] }; + for (const [tag, commit] of commits) { + const tool = TOOLS.find((name) => tag.startsWith(`${name}_v`)); + if (typeof tool !== "undefined") { + releases[tool].push({ tag: appsTags.get(commit) ?? tag, version: tag.slice(`${tool}_v`.length) }); + } + } + for (const tool of TOOLS) { + releases[tool].sort((first, second) => compareVersions(first.version, second.version)); + } + return releases; } -function readToolVersions(tool: Tool, peerDependencies: JsonRecord, devDependencies: JsonRecord): Versions { +async function readPublishedVersions(tool: Tool): Promise<{ latest: string; versions: Set }> { + // The abbreviated packument is enough to list the published versions + const response = await fetch(`${NPM_REGISTRY_URL}${tool}`, { + headers: { accept: "application/vnd.npm.install-v1+json" }, + }); + if (!response.ok) { + throw new Error(`Could not fetch ${tool} from the npm registry (HTTP ${response.status})`); + } + const packument: unknown = await response.json(); + const { latest } = readRecord(packument, "dist-tags"); + if (typeof latest !== "string") { + throw new TypeError(`Expected ${tool} to have a latest dist-tag on npm`); + } + return { latest, versions: new Set(Object.keys(readRecord(packument, "versions"))) }; +} + +// The upper bound is the newest version tagged in oxc-project/oxc that's also on npm, since a tag can land shortly +// before the package is published +async function readLatestRelease(tool: Tool, tagged: Release[] | undefined): Promise { + const { latest, versions } = await readPublishedVersions(tool); + if (typeof tagged === "undefined") { + return { + latest, + notes: [ + `Could not list the release tags in ${OXC_REPOSITORY_URL}, so the upper bound for ${tool} is its \`latest\` dist-tag on npm.`, + ], + releases: [], + }; + } + const newest = tagged.findLast((release) => versions.has(release.version)); + if (typeof newest === "undefined") { + throw new TypeError(`Expected at least one ${tool} release tag to be published on npm`); + } + const unpublished = tagged + .filter((release) => compareVersions(release.version, newest.version) > 0) + .map((release) => release.version); + return { + latest: newest.version, + notes: + unpublished.length > 0 + ? [`${tool} ${unpublished.join(", ")} is tagged but not on npm yet, so it's left out.`] + : [], + releases: tagged, + }; +} + +async function readLatestReleases(): Promise> { + let tagged: Record | undefined; + try { + tagged = readTaggedReleases(); + } catch (error) { + process.stderr.write(`${isRecord(error) && typeof error.message === "string" ? error.message : String(error)}\n`); + } + const [oxlint, oxfmt] = await Promise.all(TOOLS.map(async (tool) => await readLatestRelease(tool, tagged?.[tool]))); + if (typeof oxlint === "undefined" || typeof oxfmt === "undefined") { + throw new TypeError("Expected a latest release for both oxlint and oxfmt"); + } + return { oxlint, oxfmt }; +} + +function readToolVersions( + tool: Tool, + peerDependencies: JsonRecord, + devDependencies: JsonRecord, + latest: string, +): Versions { const peerRange = peerDependencies[tool]; const dev = devDependencies[tool]; if (typeof peerRange !== "string" || typeof dev !== "string") { throw new TypeError(`Expected ${tool} in both peerDependencies and devDependencies`); } const [major, minor, patch] = parseVersion(peerRange); - return { peerFloor: `${major}.${minor}.${patch}`, peerRange, dev }; + return { peerFloor: `${major}.${minor}.${patch}`, peerRange, dev, latest }; } -async function readVersions(): Promise> { +async function readVersions(latestReleases: Record): Promise> { const packageJson: unknown = JSON.parse(await fs.readFile(path.join(PROJECT_DIRECTORY, "package.json"), "utf8")); const peerDependencies = readRecord(packageJson, "peerDependencies"); const devDependencies = readRecord(packageJson, "devDependencies"); return { - oxlint: readToolVersions("oxlint", peerDependencies, devDependencies), - oxfmt: readToolVersions("oxfmt", peerDependencies, devDependencies), + oxlint: readToolVersions("oxlint", peerDependencies, devDependencies, latestReleases.oxlint.latest), + oxfmt: readToolVersions("oxfmt", peerDependencies, devDependencies, latestReleases.oxfmt.latest), }; } -function installPeerFloor(directory: string, versions: Record): void { +function describeReleases(tool: Tool, versions: Versions, releases: Release[]): string[] { + return releases + .filter((release) => isInRange(release.version, versions)) + .map((release) => { + const pending = compareVersions(release.version, versions.dev) > 0 ? " (not in devDependencies yet)" : ""; + return `- ${tool} ${release.version}: [\`${release.tag}\`](${OXC_REPOSITORY_URL}/releases/tag/${release.tag})${pending}`; + }); +} + +function installVersions(directory: string, versions: Record, bound: "latest" | "peerFloor"): void { execFileSync( "npm", [ @@ -142,7 +271,7 @@ function installPeerFloor(directory: string, versions: Record): "--no-audit", "--no-fund", "--silent", - ...TOOLS.map((tool) => `${tool}@${versions[tool].peerFloor}`), + ...TOOLS.map((tool) => `${tool}@${versions[tool][bound]}`), ], { cwd: directory, stdio: "inherit", shell: process.platform === "win32" }, ); @@ -365,20 +494,27 @@ async function checkFormatting(oxfmtDirectory: string): Promise { } } -const versions = await readVersions(); +const latestReleases = await readLatestReleases(); +const versions = await readVersions(latestReleases); const workDirectory = await fs.mkdtemp(path.join(tmpdir(), "oxc-version-diff-")); try { - await fs.writeFile(path.join(workDirectory, "package.json"), '{ "private": true }\n', "utf8"); await fs.writeFile(path.join(workDirectory, "empty.json"), "{}\n", "utf8"); - installPeerFloor(workDirectory, versions); + await Promise.all( + (["peerFloor", "latest"] as const).map(async (bound) => { + const directory = path.join(workDirectory, bound); + await fs.mkdir(directory); + await fs.writeFile(path.join(directory, "package.json"), '{ "private": true }\n', "utf8"); + installVersions(directory, versions, bound); + }), + ); - const floorDirectory = (tool: Tool): string => path.join(workDirectory, "node_modules", tool); - const devDirectory = (tool: Tool): string => path.join(PROJECT_DIRECTORY, "node_modules", tool); + const floorDirectory = (tool: Tool): string => path.join(workDirectory, "peerFloor", "node_modules", tool); + const latestDirectory = (tool: Tool): string => path.join(workDirectory, "latest", "node_modules", tool); const floorCatalog = readCatalog(floorDirectory("oxlint"), workDirectory); - const devCatalog = readCatalog(devDirectory("oxlint"), workDirectory); - const catalogDiff = diffCatalogs(floorCatalog, devCatalog); + const latestCatalog = readCatalog(latestDirectory("oxlint"), workDirectory); + const catalogDiff = diffCatalogs(floorCatalog, latestCatalog); const changelogFiles = TOOLS.flatMap((tool) => CHANGELOGS[tool].map((file) => { @@ -392,7 +528,7 @@ try { ); const schemaDiffs = await Promise.all( TOOLS.map(async (tool) => - diffSchemas(await readSchema(floorDirectory(tool)), await readSchema(devDirectory(tool))), + diffSchemas(await readSchema(floorDirectory(tool)), await readSchema(latestDirectory(tool))), ), ); @@ -402,11 +538,19 @@ try { ...parseArgs({ allowPositionals: true }).positionals, ]); - let report = "# oxlint/oxfmt peer-floor → devDependency diff\n\n"; - report += "| Package | peerDependencies | Peer floor | devDependencies |\n| --- | --- | --- | --- |\n"; + let report = "# oxlint/oxfmt peer-floor → latest release diff\n\n"; + report += + "| Package | peerDependencies | Peer floor | devDependencies | Latest release |\n| --- | --- | --- | --- | --- |\n"; for (const tool of TOOLS) { - report += `| ${tool} | \`${versions[tool].peerRange}\` | ${versions[tool].peerFloor} | ${versions[tool].dev} |\n`; + report += `| ${tool} | \`${versions[tool].peerRange}\` | ${versions[tool].peerFloor} | ${versions[tool].dev} | ${versions[tool].latest} |\n`; + } + const notes = TOOLS.flatMap((tool) => latestReleases[tool].notes); + if (notes.length > 0) { + report += `\n${notes.map((note) => `- ${note}`).join("\n")}\n`; } + report += "\n## Releases in range\n\n"; + const releaseLines = TOOLS.flatMap((tool) => describeReleases(tool, versions[tool], latestReleases[tool].releases)); + report += releaseLines.length > 0 ? `${releaseLines.join("\n")}\n` : "None.\n"; report += "\n## oxlint rule catalog changes\n\n"; report += catalogDiff.lines.length > 0 ? `${catalogDiff.lines.join("\n")}\n` : "No changes.\n"; for (const [index, tool] of TOOLS.entries()) { @@ -417,7 +561,7 @@ try { report += "\n## Status of affected rules in our config\n\n"; report += `${[...statusRules] .toSorted() - .map((rule) => describeRuleStatus(rule, devCatalog)) + .map((rule) => describeRuleStatus(rule, latestCatalog)) .join("\n")}\n`; report += "\n## Formatting check with the peer-floor oxfmt\n\n"; report += await checkFormatting(floorDirectory("oxfmt"));