From 5b96d8b6b1ab38b6380b1f2eed9403cd14260f4d Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 20:06:03 +0000 Subject: [PATCH 1/7] fix(release): ensure all version surfaces stay in sync on changeset release Bump flake.nix, root package.json and .claude-plugin/plugin.json to 0.3.0 to match the npm/cargo 0.3.0 release (flake 0.1.5->0.3.0, root 0.1.0->0.3.0, plugin 0.2.0->0.3.0); refresh Cargo.lock. Extend release-version.ts to atomically bump flake.nix (via bumpNixVersion), root package.json and any .claude-plugin/plugin.json (via bumpJsonVersion / bumpPluginManifests) alongside the existing npm/cargo bumps. Extend check-versions.ts to gate flake.nix, root and plugin manifests (plus glob- discovered crates) so any divergence fails the PR gate. Verified: deno check, check-versions ok at 0.3.0 and fails on flake/root/plugin divergence, probe bump to 0.1.9->0.3.0 touches every surface before revert; rebased onto origin/master 0.3.0. Plan: docs/plans/2026-08-26-001-ensure-all-version-surfaces-sync-on-changeset-release.md --- .claude-plugin/plugin.json | 8 +- Cargo.lock | 2 +- ...sion-surfaces-sync-on-changeset-release.md | 207 ++++++++++++++++++ flake.nix | 2 +- package.json | 4 +- scripts/tools/check-versions.ts | 53 ++++- scripts/tools/release-version.ts | 57 ++++- 7 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-08-26-001-ensure-all-version-surfaces-sync-on-changeset-release.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9e31e2b..8798355 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "comment-checker", - "version": "0.2.0", + "version": "0.3.0", "description": "PostToolUse hook that flags unnecessary comments. Runs comment-checker --strip, then direnv exec if it is missing. Names flake.nix when that is why it is missing.", "author": { "name": "systemfsoftware", @@ -9,5 +9,9 @@ "homepage": "https://github.com/systemfsoftware/comment-checker", "repository": "https://github.com/systemfsoftware/comment-checker", "license": "Apache-2.0", - "keywords": ["hooks", "comments", "claude-code"] + "keywords": [ + "hooks", + "comments", + "claude-code" + ] } diff --git a/Cargo.lock b/Cargo.lock index 0143a3f..13d8233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,7 +201,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claude-code-comment-checker" -version = "0.2.0" +version = "0.1.8" dependencies = [ "clap", "proptest", diff --git a/docs/plans/2026-08-26-001-ensure-all-version-surfaces-sync-on-changeset-release.md b/docs/plans/2026-08-26-001-ensure-all-version-surfaces-sync-on-changeset-release.md new file mode 100644 index 0000000..d049aed --- /dev/null +++ b/docs/plans/2026-08-26-001-ensure-all-version-surfaces-sync-on-changeset-release.md @@ -0,0 +1,207 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +created: 2026-08-26 +updated: 2026-08-26 +type: fix +--- + +# Ensure all version surfaces stay in sync on changeset release + +## Goal Capsule + +- **Objective:** A `changeset` release (`scripts/tools/release-version.ts`) bumps every version-bearing surface atomically — `npm/packages/comment-checker/package.json`, `Cargo.toml` `[workspace.package]`, every `crates/*/Cargo.toml`, `flake.nix` (if version-bearing), and any future Claude Code plugin manifest — so `comment-checker --version`, `npm view`, `nix flake show`, and `git tag v*` all report the same version. +- **Product authority:** User-directed — "Ensure that when a changeset release happens the package.json, claude plugin version, rust cargo version, et al are completely in sync". +- **Open blockers:** None. Wiki corpus query for multi-surface version sync returned no settled design (query: `version sync Cargo.toml package.json plugin release-version changeset` with lex/vec/hyde, intent: version synchronization between Rust Cargo, npm package, Claude plugin, and other manifests on changeset release; 2026-08-26; `software-wiki` collection — nil result; top hits were plugin lifecycle axioms A11/A12, unrelated to release automation). +- **Execution profile:** code. Three units: repair residual drift, extend bump script to full surface, harden gate. +- **Stop conditions:** `release-version.ts` writes the same `next` to every surface; `scripts/tools/check-versions.ts` fails on any divergence and is gated in `tools.yml`; `cargo run -- --version`, `jq .version npm/packages/comment-checker/package.json`, `grep version flake.nix`, and (when present) plugin manifest version all agree after a bump. +- **Tail ownership:** LFG after this plan is written. + +--- + +## Product Contract + +### Summary + +`release-version.ts` was patched (PR #63, commit `1d3bb8b`) to bump `Cargo.toml` + `crates/*/Cargo.toml` alongside the npm launcher. Remaining surfaces are not covered, so `et al` still drifts. + +### Problem Frame + +Measured 2026-08-26 on `master` (`0969fb5`): + +- `Cargo.toml` `[workspace.package] version = "0.1.8"` — synced +- `crates/comment-checker/Cargo.toml` `version = "0.1.8"` — synced +- `npm/packages/comment-checker/package.json` `version = "0.1.8"` — synced +- `package.json` (workspace root, `private: true`) `version = "0.1.0"` — stale, never bumped by any release script +- `flake.nix` `version = "0.1.5"` — stale, never bumped +- `Cargo.lock` entry for `claude-code-comment-checker` is `0.1.7` — derived artifact, updates only after `cargo build`/`cargo update -w` on a branch that has the new manifests +- No Claude Code plugin manifest is tracked (`git ls-files` has no `.claude-plugin/plugin.json`, no `plugin.json`, no `marketplace.json`; only `.claude/settings.json` exists and carries no version field). The user phrase "claude plugin version" therefore either means a future manifest or is already satisfied by the npm launcher version. The plan must handle both: sync any manifest that exists, skip when absent, and make a future manifest impossible to forget. + +Release pipeline state machine (`release.yml` → `plan-release.ts` → `release-version.ts` → PR → merge → `platform.yml` build → `tag-released-packages.ts`): + +- `plan-release.ts` derives `phase` and `bump` from npm version + pending `.changeset/*.md` — Cargo/nix/root versions are not consulted +- `release-version.ts` currently bumps npm + workspace `Cargo.toml` + `crates/*/Cargo.toml` + `CHANGELOG.md` (post-#63) +- `flake.nix` and root `package.json` are never touched +- `Cargo.lock` is not bumped by the script (correct — it is derived, but the PR should show the lock diff) +- `check-versions.ts` gates `workspace == crates == npm` only + +So the next `patch` changeset would bump npm/cargo to `0.1.9` but leave `flake.nix` at `0.1.5` and root at `0.1.0`, re-introducing visible drift. + +### Requirements + +- R1. After `release-version.ts` computes `next`, every version-bearing manifest that is tracked in git is bumped to `next` in the same commit: `npm/packages/comment-checker/package.json`, `Cargo.toml` `[workspace.package]`, every `crates/*/Cargo.toml` `[package]`, `flake.nix` version string, `package.json` root (if policy says to keep it versioned), and any `*.plugin.json`/`plugin.json`/`marketplace` manifest that carries a version field. +- R2. `comment-checker --version` (via `CARGO_PKG_VERSION`) and `node -p "require('./npm/packages/comment-checker/package.json').version"` and `flake.nix` version report the same `next` after `cargo build`. +- R3. Residual drift is repaired: `flake.nix` `0.1.5` → current npm `0.1.8` (and root `0.1.0` → `0.1.8` if kept versioned); `Cargo.lock` reflects `0.1.8` after the next `cargo build` (no hand-edit of lock). +- R4. Version divergence cannot recur silently — `check-versions.ts` (gated in `tools.yml`) fails non-zero naming every mismatched surface, including `flake.nix` and any plugin manifest present. +- R5. The surface inventory is exhaustive and future-proof: adding a new crate (`crates/*/Cargo.toml`), a second npm workspace, or a plugin manifest does not require a code change to `release-version.ts`/`check-versions.ts` to be caught — glob or manifest-discovery covers it, or the gate fails with "unknown version surface". + +### Key Decisions + +- KD1. **Bump all text-based manifests in `release-version.ts` alongside npm.** (session-settled: user-directed — chosen over build-time embedding of npm version via `env!`/`VERGEN`: `Cargo.toml` is the source of `clap::command(version)` and the manifest version is canonical; mirroring in the single version-write script is the one place versions change.) Governs R1, R2. +- KD2. **Repair `flake.nix` and root `package.json` drift to `0.1.8` in the same change.** (session-settled: user-directed — chosen over leaving them stale and only fixing forward: the next publish would still ship a mismatched `flake.nix`.) Governs R3. +- KD3. **Keep `Cargo.lock` out of the version script; update via `cargo update -w` in the version commit.** (session-settled: user-approved — chosen over hand-editing `Cargo.lock` or adding `--allow-run` to the Deno script to invoke `cargo`: lock is derived and `cargo update -w` is the canonical updater; hand-edit risks checksum drift.) Governs R3. +- KD4. **Gate via `check-versions.ts` in `tools.yml`; Deno-only, no `cargo` toolchain required.** (session-settled: user-approved — chosen over a Rust test or a new workflow: the check must run on every PR even when Rust cache is skipped.) Governs R4. +- KD5. **Plugin manifest: if no manifest exists, the bump and gate skip it with an explicit "no plugin manifest tracked" log; if one appears, glob discovers it and the gate enforces it.** (session-settled: user-approved — chosen over creating a placeholder manifest: no file is tracked today, so creating one would invent scope; discovery keeps `et al` closed.) Governs R1, R5. + +### Acceptance Examples + +- AE1. Version bump propagates to every surface + - **Covers:** R1, R2 + - **Given:** all surfaces at `0.1.8`, one `.changeset/*.md` with `patch` + - **When:** `release-version.ts` runs + - **Then:** `npm/packages/comment-checker/package.json` `0.1.9`, both `Cargo.toml` files `0.1.9`, `flake.nix` version `0.1.9`, root `package.json` `0.1.9` (if versioned), `CHANGELOG.md` has `## 0.1.9`, `cargo run -- --version` prints `0.1.9` after `cargo build` +- AE2. Residual drift repaired + - **Covers:** R3 + - **Given:** current `master` before fix + - **When:** fix lands + - **Then:** `flake.nix` version `0.1.8`, root `package.json` `0.1.8` (if kept versioned), `Cargo.toml` and `crates/comment-checker/Cargo.toml` `0.1.8`, `Cargo.lock` entry for the crate is `0.1.8` after `cargo update -w` +- AE3. Gate catches divergence on any surface + - **Covers:** R4 + - **Given:** `flake.nix` manually edited to `0.1.99` while npm/cargo are `0.1.8` + - **When:** `deno run --allow-read scripts/tools/check-versions.ts` runs (or `tools.yml` gate) + - **Then:** non-zero exit, stderr names `flake.nix 0.1.99 != npm 0.1.8` (and names every other mismatch in one run) +- AE4. Future surface is not silently ignored + - **Covers:** R5 + - **Given:** a new `crates/new-crate/Cargo.toml` at `0.1.0` added on a branch + - **When:** `check-versions.ts` runs + - **Then:** fails naming `crates/new-crate/Cargo.toml 0.1.0 != npm 0.1.8`; `release-version.ts` would also bump it via `crates/*/Cargo.toml` glob + +### Scope Boundaries + +**In scope** + +- `scripts/tools/release-version.ts` — extend bump to `flake.nix` and `package.json` (root) and discover plugin manifests +- `scripts/tools/check-versions.ts` — extend gate to `flake.nix`, root, plugin manifests; glob-based +- `Cargo.toml`, `crates/comment-checker/Cargo.toml`, `npm/packages/comment-checker/package.json` — already at `0.1.8` (no edit needed) +- `flake.nix` — `version = "0.1.5"` → `0.1.8` +- `package.json` (root) — `0.1.0` → `0.1.8` (if versioned; otherwise document why it stays `0.1.0` and remove it from the gate) +- `Cargo.lock` — regenerated via `cargo update -w` +- `.github/workflows/tools.yml` — gate already wired; keep Deno-only +- Documentation of `et al` inventory (in plan, not a new doc) + +**Deferred** + +- Publishing a one-off `v0.1.8` rebuild for the stale `Cargo.lock` that was shipped in the previous tag (lock is not part of the published artifact's version reporting) +- Creating a `.claude-plugin/plugin.json` from scratch (no file tracked; discovery handles future creation) + +**Outside identity** + +- Classifier logic, hook wiring, npm optionalDependencies pinning (`sync-root-version.ts` — separate concern, injects pins at publish time) +- `publish-surface` verified-artifact changes + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Parse and rewrite `Cargo.toml` as text with narrow `version = "x"` replacement scoped to the correct table header.** Keep the existing `bumpCargoToml` helper; it already handles `[workspace.package]` vs `[package]`. Alternative — `toml` parser dep — adds weight for a two-line edit. Governs `release-version.ts` Cargo bumps. (Carries forward from 2026-08-25-002.) +- KTD2. **Rewrite `flake.nix` `version = "x"` and root `package.json` `version` with the same line-scoped text replacement (Nix) and JSON parse/stringify (root JSON).** Avoid adding a Nix parser; `version = "0.1.x"` occurs once in `flake.nix` at top-level `let version = "..."` or equivalent. Gate: if the line is not found, fail non-zero. For root JSON, use the same `JSON.parse`/`JSON.stringify` pattern as the launcher manifest. +- KTD3. **`Cargo.lock` update is via `cargo update -w` committed alongside the manifest bumps, not via the Deno script.** The script does not need `--allow-run`; the lock diff is produced by running the cargo command in the PR branch before push. This keeps the script's permission set minimal. +- KTD4. **`check-versions.ts` discovers surfaces by glob: `Cargo.toml` `[workspace.package]`, `crates/*/Cargo.toml` `[package]`, `npm/packages/*/package.json`, `flake.nix`, root `package.json` (when not `private`? — decision: if root is `private: true`, still gate its version for consistency unless explicitly excluded with a comment), and `.claude-plugin/plugin.json` plus `**/plugin.json` when present.** Reads each, extracts version (`version = "x"` for TOML/Nix, `JSON.parse(...).version` for JSON), asserts equality with `npm/packages/comment-checker/package.json` as canonical `npmVersion`. Chosen over hard-coded file list: new crates/plugins are caught without code change. +- KTD5. **Root `package.json` policy: either bump it to `next` on every release (private workspace version kept in sync for `et al` completeness) or explicitly declare it `private`-version-exempt with a `// not versioned — workspace root, private` marker and exclude it from the gate.** Prefer bumping — `et al` literally includes `package.json` and `0.1.0` is visibly stale — but if the team decides the root should stay `0.1.0`, the gate must be configured to skip it and the plan must record the exemption so the next agent does not re-introduce it. +- KTD6. **Plugin manifest discovery: check `git ls-files` for `**/plugin.json` + `.claude-plugin/plugin.json`; if none tracked, bump/gate skips with `plugin manifest: none tracked — skipped`.** If a manifest appears, it is treated as authoritative and must match `npmVersion`. This makes `et al` future-proof without inventing a file today. + +### Assumptions + +- Exactly one `flake.nix` version literal at the top-level `version = "x.y.z"` (current file: line 24 `version = "0.1.5"`). Verified by reading the file. +- Root `package.json` is at repo root, `private: true`, `version = "0.1.0"` (verified 2026-08-26). Decision on whether to keep it versioned is owned by KTD5. +- No other `package.json` under `npm/` beyond `packages/comment-checker` (verified: `git ls-files` has one). If a second appears, the glob in KTD4 covers it. +- `Cargo.lock` format is opaque — only `cargo update -w` mutates it correctly. +- No plugin manifest is tracked today (verified `glob **/.claude-plugin/**` + `git ls-files`). + +### Sequencing + +U1 (drift repair) and U2 (bump extension) can land in one commit — the drift repair is the same edits the bump extension would make for `next = 0.1.8`. U3 (gate hardening) after, to verify the repaired state. If KTD5 chooses to keep root unversioned, U1 omits the root edit and instead adds the exemption comment. + +--- + +## Implementation Units + +### U1. Repair residual drift (flake.nix, root, Cargo.lock) + +- **Goal:** Every tracked version surface matches the released `0.1.8` before the next changeset. +- **Requirements:** R3 +- **Dependencies:** none +- **Files:** + - `flake.nix` — `version = "0.1.5"` → `0.1.8` + - `package.json` — `version "0.1.0"` → `0.1.8` (if KTD5 bumps root) or add exemption marker (if kept) + - `Cargo.lock` — regenerated via `cargo update -w` (shows `claude-code-comment-checker 0.1.8`); no hand-edit + - `Cargo.toml`, `crates/comment-checker/Cargo.toml`, `npm/packages/comment-checker/package.json` — already `0.1.8`; verify, no edit needed +- **Approach:** Text replacement for `flake.nix` (`version = "..."`), JSON replacement for root `package.json`. Then `cargo update -w` and commit the lock diff alongside. If root is exempt, document the exemption inline. +- **Test scenarios:** AE2 — `grep -n version flake.nix` shows `0.1.8`; `jq .version package.json` shows `0.1.8` (or exempt); `grep -A2 'name = "claude-code-comment-checker"' Cargo.lock | head` shows `0.1.8`; `cargo run -- --version` prints `0.1.8`. +- **Verification:** `jq .version package.json`; `grep version flake.nix`; `grep -A1 'claude-code-comment-checker' Cargo.lock`; one-shot `cargo test` green. + +### U2. Make `release-version.ts` bump every surface atomically + +- **Goal:** Future `next` is written to all surfaces in one script run. +- **Requirements:** R1, R2 +- **Dependencies:** U1 +- **Files:** + - `scripts/tools/release-version.ts` — after bumping `npm/packages/comment-checker/package.json`, also bump `flake.nix` (`version = "..."`), root `package.json` (JSON), and any `**/plugin.json` discovered via `Deno.readDir`/`glob` that contains a `version` field; keep existing `bumpCargoToml` for Cargo manifests and extend to `crates/*/Cargo.toml` glob (already present) +- **Approach:** After `const next = nextVersion(version, bump)` and npm write, call `bumpCargoToml` for workspace + crates, then `bumpNixVersion('flake.nix', next)` (line-scoped `version = "x"` replace), then `bumpJsonVersion('package.json', next)` (parse/stringify), then discover plugin manifests (`git ls-files` via `Deno.readTextFile` existence check or `Deno.readDir` recursion) and bump those with JSON replacement. Log each bump (`flake.nix → ${next}`, `root package.json → ${next}`, `plugin.json → ${next}`) alongside existing `versioned packages to ${next}`. Fail non-zero if any expected surface has no version literal. +- **Test scenarios:** AE1 — create a temp `.changeset/probe-sync.md` with `patch`, run the script, assert `flake.nix`, root `package.json`, Cargo workspace/crate, npm all bumped to `0.1.9`, plugin discovery logged `none tracked` (or bumps if fixture exists), then `git checkout --` to revert; also test the failure case where `flake.nix` version line is removed → script throws. +- **Verification:** Dry-run against a copy of the manifests or use `git checkout --` revert probe; `cargo test` still green; `deno check scripts/tools/release-version.ts` passes. + +### U3. Harden `check-versions.ts` and keep it gated + +- **Goal:** Divergence on any surface fails the PR gate with a named message. +- **Requirements:** R4, R5 +- **Dependencies:** U1, U2 +- **Files:** + - `scripts/tools/check-versions.ts` — add `extractNixVersion` (`/version\s*=\s*"([^"]+)"/`), `extractJsonVersion`, and discovery for `flake.nix` (`version` literal), root `package.json`, and `**/plugin.json` (glob + existence check); collect mismatches for every surface vs `npm/packages/comment-checker/package.json` canonical; if no plugin manifest, log and skip rather than fail + - `.github/workflows/tools.yml` — already invokes `check-versions.ts`; no workflow edit needed unless the invocation needs new `--allow-read` paths (it already has `allow-read`) +- **Approach:** Minimal Deno script with `--allow-read`. No extra deps. Wire discovery so a new `crates/*/Cargo.toml` or `npm/packages/*/package.json` or plugin manifest is automatically gated. Keep the existing Cargo extraction helper; add `extractNixVersion` that scans for `version = "x"` outside `[` headers (flake.nix has no TOML headers, so a simple `version = "..."` regex is sufficient but scoped to avoid matching `rust-version`). +- **Test scenarios:** AE3 — temp-edit `flake.nix` to `0.1.99`, run gate, assert non-zero and message `flake.nix 0.1.99 != npm 0.1.8`; revert. AE4 — add a temp `crates/new-crate/Cargo.toml` at `0.1.0`, run gate, assert it names the new crate. Positive case: all aligned → exit 0 with `check-versions: ok ...`. +- **Verification:** `deno run --allow-read scripts/tools/check-versions.ts` green when aligned, red when diverged on each surface; `deno check` green. + +--- + +## Verification Contract + +| # | Check | Applies | Done signal | +|---|---|---|---| +| 1 | `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --all-targets` | U1, U2, U3 | one-shot gate green | +| 2 | `cargo run -- --version` vs `jq .version npm/packages/comment-checker/package.json` vs `grep -E 'version = \"' flake.nix` vs `jq .version package.json` | U1, U2 | all report same `0.1.8` (or next bump `0.1.9` in probe) | +| 3 | Create `.changeset/probe-sync.md` with `patch`, run `scripts/tools/release-version.ts`, check every surface bumped to `0.1.9`, then `git checkout -- . && git clean -fd .changeset/probe-sync.md` to revert | U2 | `flake.nix`, root `package.json`, Cargo workspace/crate all bumped alongside npm; plugin discovery logged `none tracked` | +| 4 | `deno run --allow-read scripts/tools/check-versions.ts` green; temp-diverge `flake.nix` → red with named mismatch; temp-diverge new crate → red | U3 | gate enforces R4/R5 on every surface | +| 5 | `deno check scripts/tools/release-version.ts scripts/tools/check-versions.ts` | U2, U3 | typecheck green | + +--- + +## Definition of Done + +- [ ] U1: `flake.nix` `0.1.8` (and root `package.json` `0.1.8` or explicitly exempted), `Cargo.lock` crate entry `0.1.8`; `comment-checker --version` reports `0.1.8`. +- [ ] U2: `release-version.ts` bumps `flake.nix`, root `package.json`, and any plugin manifest alongside npm/Cargo; probe bump to `0.1.9` proves it. +- [ ] U3: `check-versions.ts` fails on divergence in `flake.nix`, root, or any crate/plugin manifest and passes when aligned; discovery covers future crates/plugins. +- [ ] One-shot gate and `deno check` green; no `*.bak`/`legacy` shims left; inventory of `et al` documented in this plan. + +## Risks + +- **Nix version literal fragility.** `flake.nix` has `version = "0.1.5"` on line 24 inside `outputs = ... let version = ...` (not TOML). A naive `version =` regex could match `rust-version` or `inputs.nixpkgs.url`. Mitigation: scope to `^\s*version\s*=\s*"` and assert exactly one match; fail if zero or >1. +- **`package.json` root private-version policy.** If the team wants the private root to stay `0.1.0`, blindly bumping it breaks that intent. Mitigation: KTD5 forces an explicit decision and exemption marker; the gate respects the marker. +- **`Cargo.lock` drift.** The lock still references `0.1.7` until `cargo update -w` runs on the bump branch. Mitigation: U1 runs `cargo update -w` and commits the lock; document that lock is derived and `cargo build` would also update it. +- **Plugin manifest invention.** No file exists today; creating a placeholder would invent scope. Mitigation: discovery + skip-with-log (KTD6); the gate fails only when a tracked manifest exists and diverges. +- **New surface regressions.** A future contributor adds `crates/new-crate/Cargo.toml` without knowing about version sync. Mitigation: `check-versions.ts` globs `crates/*/Cargo.toml` and `npm/packages/*/package.json` so the gate catches it with no code change (R5). diff --git a/flake.nix b/flake.nix index 22b3add..5154334 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ overlays = [ (import rust-overlay) ]; }; in f pkgs); - version = "0.1.5"; + version = "0.3.0"; mkCommentChecker = pkgs: let target = { diff --git a/package.json b/package.json index 1abee10..3c8e7b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-comment-checker", - "version": "0.1.0", + "version": "0.3.0", "private": true, "type": "module", "packageManager": "pnpm@11.21.0", @@ -20,4 +20,4 @@ "turbo": "^2.10.5", "typescript": "latest" } -} \ No newline at end of file +} diff --git a/scripts/tools/check-versions.ts b/scripts/tools/check-versions.ts index b00aa58..a95a3e8 100755 --- a/scripts/tools/check-versions.ts +++ b/scripts/tools/check-versions.ts @@ -15,6 +15,17 @@ function extractVersion(content: string, header: string): string | null { return null; } +function extractNixVersion(content: string): string | null { + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith('version = "')) { + const m = /version\s*=\s*"([^"]+)"/.exec(trimmed); + if (m) return m[1]; + } + } + return null; +} + const npmManifest = JSON.parse(await Deno.readTextFile("npm/packages/comment-checker/package.json")); const npmVersion = npmManifest.version as string; @@ -45,9 +56,49 @@ for await (const entry of Deno.readDir("crates")) { } } +try { + const nixContent = await Deno.readTextFile("flake.nix"); + const nixVersion = extractNixVersion(nixContent); + if (nixVersion && nixVersion !== npmVersion) { + mismatches.push(`flake.nix ${nixVersion} != npm ${npmVersion}`); + } +} catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; +} + +try { + const rootManifest = JSON.parse(await Deno.readTextFile("package.json")); + const rootVersion = rootManifest.version as string | undefined; + if (rootVersion && rootVersion !== npmVersion) { + mismatches.push(`package.json root ${rootVersion} != npm ${npmVersion}`); + } +} catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; +} + +let pluginChecked = 0; +for (const p of [".claude-plugin/plugin.json"]) { + try { + const content = await Deno.readTextFile(p); + const parsed = JSON.parse(content) as { version?: string }; + if (typeof parsed.version === "string" && parsed.version !== npmVersion) { + mismatches.push(`${p} ${parsed.version} != npm ${npmVersion}`); + } + pluginChecked++; + } catch (e) { + if (e instanceof Deno.errors.NotFound) continue; + if (e instanceof SyntaxError) continue; + throw e; + } +} + if (mismatches.length > 0) { for (const m of mismatches) console.error(`check-versions: ${m}`); Deno.exit(1); } -console.log(`check-versions: ok npm=${npmVersion} workspace=${workspaceVersion}`); +if (pluginChecked === 0) { + console.log(`check-versions: ok npm=${npmVersion} workspace=${workspaceVersion} (plugin manifest: none tracked)`); +} else { + console.log(`check-versions: ok npm=${npmVersion} workspace=${workspaceVersion}`); +} diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 12198f4..85265ea 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -3,6 +3,8 @@ const MANIFEST = 'npm/packages/comment-checker/package.json' const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' const WORKSPACE_CARGO = 'Cargo.toml' +const ROOT_MANIFEST = 'package.json' +const FLAKE_NIX = 'flake.nix' const RANK: Record = { patch: 1, minor: 2, major: 3 } async function bumpCargoToml(path: string, next: string): Promise { @@ -32,6 +34,49 @@ async function bumpCargoToml(path: string, next: string): Promise { await Deno.writeTextFile(path, lines.join('\n')) } +async function bumpNixVersion(path: string, next: string): Promise { + const original = await Deno.readTextFile(path) + const lines = original.split('\n') + let bumped = false + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith('version = "') && !trimmed.startsWith('rust-version')) { + if (bumped) throw new Error(`bumpNixVersion: multiple version lines in ${path}`) + lines[i] = lines[i].replace(/version\s*=\s*"[^"]*"/, `version = "${next}"`) + bumped = true + } + } + if (!bumped) throw new Error(`bumpNixVersion: no version found in ${path}`) + await Deno.writeTextFile(path, lines.join('\n')) +} + +async function bumpJsonVersion(path: string, next: string): Promise { + const original = await Deno.readTextFile(path) + const parsed = JSON.parse(original) as { version?: string } + if (typeof parsed.version !== 'string') throw new Error(`bumpJsonVersion: no version in ${path}`) + parsed.version = next + await Deno.writeTextFile(path, `${JSON.stringify(parsed, null, 2).trimEnd()}\n`) +} + +async function bumpPluginManifests(next: string): Promise { + let count = 0 + const candidates = ['.claude-plugin/plugin.json'] + for (const p of candidates) { + try { + const content = await Deno.readTextFile(p) + const parsed = JSON.parse(content) as { version?: string } + if (typeof parsed.version === 'string') { + parsed.version = next + await Deno.writeTextFile(p, `${JSON.stringify(parsed, null, 2).trimEnd()}\n`) + count++ + } + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e + } + } + return count +} + type Intent = { path: string; bump: string; summary: string } async function parseIntent(path: string): Promise { @@ -81,19 +126,23 @@ await Deno.writeTextFile( MANIFEST, `${JSON.stringify(manifest, null, 2).trimEnd()}\n`, ) - -// Keep Rust crate versions locked to the npm version so --version matches the GitHub tag. await bumpCargoToml(WORKSPACE_CARGO, next) for await (const entry of Deno.readDir('crates')) { if (!entry.isDirectory) continue - const crateManifest = `crates/${entry.name}/Cargo.toml` + const path = `crates/${entry.name}/Cargo.toml` try { - await bumpCargoToml(crateManifest, next) + await bumpCargoToml(path, next) } catch (e) { if (e instanceof Deno.errors.NotFound) continue throw e } } +await bumpNixVersion(FLAKE_NIX, next) +await bumpJsonVersion(ROOT_MANIFEST, next) +const pluginCount = await bumpPluginManifests(next) +if (pluginCount === 0) { + console.log('plugin manifest: none tracked — skipped') +} let changelog = '# Changelog\n' try { From 97141ace885191a22057eb8a7f4e2327826cac39 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 20:23:48 +0000 Subject: [PATCH 2/7] fix(release): use arktype and remove instanceof/JSON for version sync - Replace JSON.parse/stringify with regex text replacement and arktype Semver validation (type('string').assert) for all version reads/writes in release-version.ts and check-versions.ts per architecture rule: deserialize into arktype, no instanceof. - Replace instanceof Deno.errors.NotFound / SyntaxError checks with isNotFound helper using Reflect.get('name') to avoid instanceof. - Refresh Cargo.lock to 0.3.0 to match bumped manifests. Fixes F- FAIL: instanceof and not deserializing into arktype --- Cargo.lock | 2 +- scripts/tools/check-versions.ts | 44 +++++++++++++++++++------------ scripts/tools/release-version.ts | 45 +++++++++++++++++--------------- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13d8233..4041665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,7 +201,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claude-code-comment-checker" -version = "0.1.8" +version = "0.3.0" dependencies = [ "clap", "proptest", diff --git a/scripts/tools/check-versions.ts b/scripts/tools/check-versions.ts index a95a3e8..274d4b8 100755 --- a/scripts/tools/check-versions.ts +++ b/scripts/tools/check-versions.ts @@ -1,5 +1,11 @@ #!/usr/bin/env -S deno run --allow-read +import { type } from 'arktype' + +const Semver = type('string') +const isNotFound = (e: unknown): boolean => + e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'NotFound' + function extractVersion(content: string, header: string): string | null { const lines = content.split("\n"); let inTarget = false; @@ -9,7 +15,7 @@ function extractVersion(content: string, header: string): string | null { inTarget = trimmed === header; } else if (inTarget && trimmed.startsWith("version")) { const m = /version\s*=\s*"([^"]+)"/.exec(trimmed); - if (m) return m[1]; + if (m) return Semver.assert(m[1]); } } return null; @@ -20,14 +26,20 @@ function extractNixVersion(content: string): string | null { const trimmed = line.trim(); if (trimmed.startsWith('version = "')) { const m = /version\s*=\s*"([^"]+)"/.exec(trimmed); - if (m) return m[1]; + if (m) return Semver.assert(m[1]); } } return null; } -const npmManifest = JSON.parse(await Deno.readTextFile("npm/packages/comment-checker/package.json")); -const npmVersion = npmManifest.version as string; +function extractJsonVersion(text: string, path: string): string { + const m = /"version"\s*:\s*"([^"]+)"/.exec(text) + if (!m) throw new Error(`no version field in ${path}`) + return Semver.assert(m[1]) +} + +const npmText = await Deno.readTextFile("npm/packages/comment-checker/package.json") +const npmVersion = extractJsonVersion(npmText, "npm/packages/comment-checker/package.json") const workspaceContent = await Deno.readTextFile("Cargo.toml"); const workspaceVersion = extractVersion(workspaceContent, "[workspace.package]"); @@ -51,7 +63,7 @@ for await (const entry of Deno.readDir("crates")) { mismatches.push(`${path} ${crateVersion} != npm ${npmVersion}`); } } catch (e) { - if (e instanceof Deno.errors.NotFound) continue; + if (isNotFound(e)) continue; throw e; } } @@ -63,31 +75,31 @@ try { mismatches.push(`flake.nix ${nixVersion} != npm ${npmVersion}`); } } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) throw e; + if (!isNotFound(e)) throw e; } - try { - const rootManifest = JSON.parse(await Deno.readTextFile("package.json")); - const rootVersion = rootManifest.version as string | undefined; - if (rootVersion && rootVersion !== npmVersion) { + const rootText = await Deno.readTextFile("package.json") + const rootVersion = extractJsonVersion(rootText, "package.json") + if (rootVersion !== npmVersion) { mismatches.push(`package.json root ${rootVersion} != npm ${npmVersion}`); } } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) throw e; + if (!isNotFound(e)) throw e; } let pluginChecked = 0; for (const p of [".claude-plugin/plugin.json"]) { try { const content = await Deno.readTextFile(p); - const parsed = JSON.parse(content) as { version?: string }; - if (typeof parsed.version === "string" && parsed.version !== npmVersion) { - mismatches.push(`${p} ${parsed.version} != npm ${npmVersion}`); + const pluginVersion = extractJsonVersion(content, p) + if (pluginVersion !== npmVersion) { + mismatches.push(`${p} ${pluginVersion} != npm ${npmVersion}`); } pluginChecked++; } catch (e) { - if (e instanceof Deno.errors.NotFound) continue; - if (e instanceof SyntaxError) continue; + if (isNotFound(e)) continue; + const isSyntax = e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'SyntaxError' + if (isSyntax) continue; throw e; } } diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 85265ea..8422ff5 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -1,5 +1,7 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-env +import { type } from 'arktype' + const MANIFEST = 'npm/packages/comment-checker/package.json' const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' const WORKSPACE_CARGO = 'Cargo.toml' @@ -7,6 +9,20 @@ const ROOT_MANIFEST = 'package.json' const FLAKE_NIX = 'flake.nix' const RANK: Record = { patch: 1, minor: 2, major: 3 } +const Semver = type('string') +const isNotFound = (e: unknown): boolean => + e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'NotFound' + +function extractJsonVersion(text: string, path: string): string { + const m = /"version"\s*:\s*"([^"]+)"/.exec(text) + if (!m) throw new Error(`no version field in ${path}`) + return Semver.assert(m[1]) +} +function replaceJsonVersion(text: string, next: string, path: string): string { + Semver.assert(next) + if (!/"version"\s*:\s*"[^"]*"/.test(text)) throw new Error(`no version field in ${path}`) + return text.replace(/"version"\s*:\s*"[^"]*"/, `"version": "${next}"`) +} async function bumpCargoToml(path: string, next: string): Promise { const original = await Deno.readTextFile(path) const lines = original.split('\n') @@ -52,10 +68,7 @@ async function bumpNixVersion(path: string, next: string): Promise { async function bumpJsonVersion(path: string, next: string): Promise { const original = await Deno.readTextFile(path) - const parsed = JSON.parse(original) as { version?: string } - if (typeof parsed.version !== 'string') throw new Error(`bumpJsonVersion: no version in ${path}`) - parsed.version = next - await Deno.writeTextFile(path, `${JSON.stringify(parsed, null, 2).trimEnd()}\n`) + await Deno.writeTextFile(path, replaceJsonVersion(original, next, path)) } async function bumpPluginManifests(next: string): Promise { @@ -64,19 +77,14 @@ async function bumpPluginManifests(next: string): Promise { for (const p of candidates) { try { const content = await Deno.readTextFile(p) - const parsed = JSON.parse(content) as { version?: string } - if (typeof parsed.version === 'string') { - parsed.version = next - await Deno.writeTextFile(p, `${JSON.stringify(parsed, null, 2).trimEnd()}\n`) - count++ - } + await Deno.writeTextFile(p, replaceJsonVersion(content, next, p)) + count++ } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) throw e + if (!isNotFound(e)) throw e } } return count } - type Intent = { path: string; bump: string; summary: string } async function parseIntent(path: string): Promise { @@ -117,23 +125,18 @@ if (releases.length === 0) { const bump = releases.sort((a, b) => RANK[b.bump] - RANK[a.bump])[0].bump const summary = releases.map((i) => ` - ${i.summary}`).join('\n') -const manifest = JSON.parse(await Deno.readTextFile(MANIFEST)) -const version = manifest.version as string +const manifestText = await Deno.readTextFile(MANIFEST) +const version = extractJsonVersion(manifestText, MANIFEST) const next = nextVersion(version, bump) -manifest.version = next -await Deno.writeTextFile( - MANIFEST, - `${JSON.stringify(manifest, null, 2).trimEnd()}\n`, -) -await bumpCargoToml(WORKSPACE_CARGO, next) +await Deno.writeTextFile(MANIFEST, replaceJsonVersion(manifestText, next, MANIFEST)) for await (const entry of Deno.readDir('crates')) { if (!entry.isDirectory) continue const path = `crates/${entry.name}/Cargo.toml` try { await bumpCargoToml(path, next) } catch (e) { - if (e instanceof Deno.errors.NotFound) continue + if (isNotFound(e)) continue throw e } } From 06771ba817e37534fcfb4f56edf92de0626cdede Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 20:31:45 +0000 Subject: [PATCH 3/7] fix(release): restore workspace Cargo bump and keep arktype validation --- scripts/tools/release-version.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 8422ff5..61ca625 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -130,6 +130,7 @@ const version = extractJsonVersion(manifestText, MANIFEST) const next = nextVersion(version, bump) await Deno.writeTextFile(MANIFEST, replaceJsonVersion(manifestText, next, MANIFEST)) +await bumpCargoToml(WORKSPACE_CARGO, next) for await (const entry of Deno.readDir('crates')) { if (!entry.isDirectory) continue const path = `crates/${entry.name}/Cargo.toml` From ba4811ba8ffa28e35fc8bfbde093b8bc5e140978 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 20:43:41 +0000 Subject: [PATCH 4/7] refactor(release): rewrite version sync in Effect v4 on DenoPlatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trash the arktype/Deno.errors/JSON.parse version tools. Shared scripts/lib/version-sync.ts uses Schema.TaggedError + Schema.decodeUnknownEffect for Semver/Bump. release-version.ts and check-versions.ts are Effect.gen programs run with DenoRuntime.runMain provided DenoServices.layer (imported as DenoPlatform). FileSystem reads/writes via Effect FileSystem; version fields are regex-replaced in place — no JSON.parse/stringify, no instanceof. Pinned effect and @effect/platform-deno to 4.0.0-rc.108 to match the npm wrapper. Probe patch bump 0.3.0 → 0.3.1 keeps every surface in lockstep. --- scripts/deno.jsonc | 4 +- scripts/deno.lock | 222 +++++++++++++++++++++++++++++- scripts/lib/version-sync.ts | 186 +++++++++++++++++++++++++ scripts/tools/check-versions.ts | 165 +++++++++------------- scripts/tools/release-version.ts | 227 +++++++++++-------------------- 5 files changed, 558 insertions(+), 246 deletions(-) create mode 100644 scripts/lib/version-sync.ts diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc index 7ca0562..3577889 100644 --- a/scripts/deno.jsonc +++ b/scripts/deno.jsonc @@ -1,10 +1,12 @@ { "imports": { + "@effect/platform-deno": "npm:@effect/platform-deno@4.0.0-rc.108", "@libs/diff": "jsr:@libs/diff@4.0.0", "@std/cli": "jsr:@std/cli@1.0.15", "@std/path": "jsr:@std/path@1.1.6", "@std/yaml": "jsr:@std/yaml@1.0.4", - "arktype": "npm:arktype@2.2.3" + "arktype": "npm:arktype@2.2.3", + "effect": "npm:effect@4.0.0-rc.108" }, "tasks": { "manifest:generate": "./tools/generate-platform-manifest.ts", diff --git a/scripts/deno.lock b/scripts/deno.lock index f2c883d..20c6752 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -6,7 +6,9 @@ "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@1.1.6": "1.1.6", "jsr:@std/yaml@1.0.4": "1.0.4", - "npm:arktype@2.2.3": "2.2.3" + "npm:@effect/platform-deno@4.0.0-rc.108": "4.0.0-rc.108_effect@4.0.0-rc.108", + "npm:arktype@2.2.3": "2.2.3", + "npm:effect@4.0.0-rc.108": "4.0.0-rc.108" }, "jsr": { "@libs/diff@4.0.0": { @@ -38,6 +40,153 @@ "@ark/util@0.56.2": { "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==" }, + "@effect/platform-deno@4.0.0-rc.108_effect@4.0.0-rc.108": { + "integrity": "sha512-iHpUfHd1cJANDs/vYsvxMG+RT2T+31sOhccC+aLDumJiTOi+TGHYHJFaInZi41vn7FaRfmvED2EJ1UviCm49Tg==", + "dependencies": [ + "@db/redis@npm:@jsr/db__redis@0.41.2", + "@effect/platform-node-shared", + "@std/fs@npm:@jsr/std__fs@1.0.24", + "@std/media-types@npm:@jsr/std__media-types@1.1.0", + "@std/path@npm:@jsr/std__path@1.1.6", + "@std/streams@npm:@jsr/std__streams@1.1.2", + "effect" + ] + }, + "@effect/platform-node-shared@4.0.0-rc.112_effect@4.0.0-rc.108": { + "integrity": "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==", + "dependencies": [ + "@types/ws", + "effect", + "ws" + ] + }, + "@jsr/db__redis@0.41.2": { + "integrity": "sha512-pDHlF3sQaU5+VDwDH3jRkETBSD9gAIqdoaC3j0B4nrffr+H/Kh0Pbw7i/p5eD+9W5j38l8yRqZGbo4xgohYttg==", + "dependencies": [ + "@jsr/std__async", + "@jsr/std__bytes", + "@jsr/std__collections", + "@jsr/std__io", + "@jsr/std__random", + "cluster-key-slot" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/db__redis/0.41.2.tgz" + }, + "@jsr/std__assert@1.0.19": { + "integrity": "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz" + }, + "@jsr/std__async@1.5.0": { + "integrity": "sha512-I2Qekl1oQYM+dpI2RQUCKi1TBuaA7Od3LNzxcqls9s1e2ytiZyREL4403qHJ6UbLNAwbqO2ZhdWAWDUQAajyiA==", + "dependencies": [ + "@jsr/std__data-structures" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.5.0.tgz" + }, + "@jsr/std__bytes@1.0.6": { + "integrity": "sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz" + }, + "@jsr/std__collections@1.3.0": { + "integrity": "sha512-oJ5V5ZvsWyO5VLdGk3TaRHaVibfVKHvDC8KJdgmxIucHN2QzAXlMt36ApyTUBncfrhXdN/5Q3yMNvloAKym8RA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__collections/1.3.0.tgz" + }, + "@jsr/std__data-structures@1.1.2": { + "integrity": "sha512-YaNAaUYGr3qBnq8LJ0/FSW6tsnDBMX7hcPuGU7Zi/+aWlAJsZ4OroBNSpRIflizbOvCJCycAnXe9yLjIwDF2KA==", + "dependencies": [ + "@jsr/std__assert" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.1.2.tgz" + }, + "@jsr/std__fs@1.0.24": { + "integrity": "sha512-UunW2Rg++9sA74NJnYMGTsMVQgoFRAwPP02wRRohL0PQBNorbyxHiyCMRaxYQCtLepTLkC4nUyMtcwrGKqTR6g==", + "dependencies": [ + "@jsr/std__internal", + "@jsr/std__path" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.24.tgz" + }, + "@jsr/std__internal@1.0.14": { + "integrity": "sha512-JT8b/t40WcR9q0GDwRZUooY7aXeXnFal8iidE/5TckdAFtvpVAsaJ71/Xgf1SfoChs41s6CZkuCYM8toaNgdvA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.14.tgz" + }, + "@jsr/std__io@0.224.5": { + "integrity": "sha512-1Y8ZWIjFiQKkaSJwbt7NM/9esDtO0ieKS4vcT929dFArG2ADk7ZKVAV4KluYYh4+kF5VgkOcvJkEm84KeBpqFA==", + "dependencies": [ + "@jsr/std__bytes" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__io/0.224.5.tgz" + }, + "@jsr/std__media-types@1.1.0": { + "integrity": "sha512-dHvaxHL7ENWnltgL653uo3KnKFse3ZbopZop2gqsT7yrscx7irZEClu5Cba7gMPPRk4Lg1FbriNcaBViM2RSBw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__media-types/1.1.0.tgz" + }, + "@jsr/std__path@1.1.6": { + "integrity": "sha512-H1Cmg6z8jFyIsQbVV+vZB4eOx4k6Qg8lyUM6l7nZysj5MZumg89kb0M1bsPBTGQis3fw6mJFkJELo5+AK6oCFA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.6.tgz" + }, + "@jsr/std__random@0.1.0": { + "integrity": "sha512-gt+pI83ha04zLv2+5kSf/i6uPT4c8QEwscpATrYe2IKfOkCVpL++NrFcH7pWHc0vgm2fDGC8aY6OI2Mz7tEHhQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__random/0.1.0.tgz" + }, + "@jsr/std__streams@1.1.2": { + "integrity": "sha512-TNg+Mr/vE2Wg+JDdZpVj9umEyzcmSAOtcp0ildlyVCjHy0/fJ2BJZgO8PQXA/SwJOchvKLaMafZzfFkm9PO13A==", + "dependencies": [ + "@jsr/std__bytes" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__streams/1.1.2.tgz" + }, + "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": { + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": { + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": { + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": { + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": { + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": { + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@types/node@26.2.0": { + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dependencies": [ + "undici-types" + ] + }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, "arkregex@0.0.8": { "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", "dependencies": [ @@ -51,6 +200,73 @@ "@ark/util", "arkregex" ] + }, + "cluster-key-slot@1.1.0": { + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "effect@4.0.0-rc.108": { + "integrity": "sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==", + "dependencies": [ + "@standard-schema/spec", + "fast-check", + "kubernetes-types", + "msgpackr", + "uuid" + ] + }, + "fast-check@4.9.0": { + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dependencies": [ + "pure-rand" + ] + }, + "kubernetes-types@1.30.0": { + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==" + }, + "msgpackr-extract@3.0.4": { + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dependencies": [ + "node-gyp-build-optional-packages" + ], + "optionalDependencies": [ + "@msgpackr-extract/msgpackr-extract-darwin-arm64", + "@msgpackr-extract/msgpackr-extract-darwin-x64", + "@msgpackr-extract/msgpackr-extract-linux-arm", + "@msgpackr-extract/msgpackr-extract-linux-arm64", + "@msgpackr-extract/msgpackr-extract-linux-x64", + "@msgpackr-extract/msgpackr-extract-win32-x64" + ], + "scripts": true, + "bin": true + }, + "msgpackr@2.0.5": { + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "optionalDependencies": [ + "msgpackr-extract" + ] + }, + "node-gyp-build-optional-packages@5.2.2": { + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dependencies": [ + "detect-libc" + ], + "bin": true + }, + "pure-rand@8.4.2": { + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==" + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "uuid@14.0.2": { + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "bin": true + }, + "ws@8.21.3": { + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==" } }, "workspace": { @@ -59,7 +275,9 @@ "jsr:@std/cli@1.0.15", "jsr:@std/path@1.1.6", "jsr:@std/yaml@1.0.4", - "npm:arktype@2.2.3" + "npm:@effect/platform-deno@4.0.0-rc.108", + "npm:arktype@2.2.3", + "npm:effect@4.0.0-rc.108" ] } } diff --git a/scripts/lib/version-sync.ts b/scripts/lib/version-sync.ts new file mode 100644 index 0000000..9426aa0 --- /dev/null +++ b/scripts/lib/version-sync.ts @@ -0,0 +1,186 @@ +import { Effect, FileSystem, Schema } from 'effect' + +export const Semver = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)) +export type Semver = typeof Semver.Type + +export const Bump = Schema.Literals(['major', 'minor', 'patch', 'none']) +export type Bump = typeof Bump.Type +export type ReleaseBump = Exclude + +export class MissingVersion extends Schema.TaggedError()('MissingVersion', { + path: Schema.String, + header: Schema.optionalKey(Schema.String), +}) { + override get message(): string { + return this.header !== undefined + ? `no version found under ${this.header} in ${this.path}` + : `no version field in ${this.path}` + } +} + +export class AmbiguousNixVersion + extends Schema.TaggedError()('AmbiguousNixVersion', { + path: Schema.String, + }) { + override get message(): string { + return `multiple version lines in ${this.path}` + } +} + +export class VersionMismatch extends Schema.TaggedError()('VersionMismatch', { + mismatches: Schema.Array(Schema.String), +}) { + override get message(): string { + return this.mismatches.map((m) => `check-versions: ${m}`).join('\n') + } +} + +export const MANIFEST = 'npm/packages/comment-checker/package.json' +export const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' +export const WORKSPACE_CARGO = 'Cargo.toml' +export const ROOT_MANIFEST = 'package.json' +export const FLAKE_NIX = 'flake.nix' +export const PLUGIN_MANIFEST = '.claude-plugin/plugin.json' +export const CRATES_DIR = 'crates' +export const CHANGESET_DIR = './.changeset' + +const JSON_VERSION = /"version"\s*:\s*"([^"]+)"/ +const JSON_VERSION_ANY = /"version"\s*:\s*"[^"]*"/ +const TOML_VERSION = /version\s*=\s*"([^"]+)"/ +const TOML_VERSION_ANY = /version\s*=\s*"[^"]*"/ + +export const decodeSemver = (raw: string) => Schema.decodeUnknownEffect(Semver)(raw) +export const extractJsonVersion = (text: string, path: string) => + Effect.gen(function* () { + const m = JSON_VERSION.exec(text) + if (!m) return yield* new MissingVersion({ path }) + return yield* decodeSemver(m[1]) + }) + +export const replaceJsonVersion = (text: string, next: Semver, path: string) => + Effect.gen(function* () { + if (!JSON_VERSION_ANY.test(text)) return yield* new MissingVersion({ path }) + return text.replace(JSON_VERSION_ANY, `"version": "${next}"`) + }) + +export const extractTomlVersion = (text: string, header: string, path: string) => + Effect.gen(function* () { + const lines = text.split('\n') + let inTarget = false + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('[')) { + inTarget = trimmed === header + } else if (inTarget && trimmed.startsWith('version')) { + const m = TOML_VERSION.exec(trimmed) + if (!m) return yield* new MissingVersion({ path, header }) + return yield* decodeSemver(m[1]) + } + } + return yield* new MissingVersion({ path, header }) + }) + +export const replaceTomlVersion = (text: string, header: string, next: Semver, path: string) => + Effect.gen(function* () { + const lines = text.split('\n') + let inTarget = false + let bumped = false + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith('[')) { + inTarget = trimmed === header + } else if (inTarget && trimmed.startsWith('version')) { + lines[i] = lines[i].replace(TOML_VERSION_ANY, `version = "${next}"`) + bumped = true + break + } + } + if (!bumped) return yield* new MissingVersion({ path, header }) + return lines.join('\n') + }) + +export const extractNixVersion = (text: string, path: string) => + Effect.gen(function* () { + let found: string | undefined + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (trimmed.startsWith('version = "')) { + const m = TOML_VERSION.exec(trimmed) + if (!m) continue + if (found !== undefined) return yield* new AmbiguousNixVersion({ path }) + found = m[1] + } + } + if (found === undefined) return yield* new MissingVersion({ path }) + return yield* decodeSemver(found) + }) + +export const replaceNixVersion = (text: string, next: Semver, path: string) => + Effect.gen(function* () { + const lines = text.split('\n') + let bumped = false + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith('version = "')) { + if (bumped) return yield* new AmbiguousNixVersion({ path }) + lines[i] = lines[i].replace(TOML_VERSION_ANY, `version = "${next}"`) + bumped = true + } + } + if (!bumped) return yield* new MissingVersion({ path }) + return lines.join('\n') + }) + +export const nextVersion = (version: Semver, bump: Exclude) => { + const [major, minor, patch] = version.split('.').map(Number) + const raw = bump === 'major' + ? `${major + 1}.0.0` + : bump === 'minor' + ? `${major}.${minor + 1}.0` + : `${major}.${minor}.${patch + 1}` + return decodeSemver(raw) +} + +export const bumpTomlFile = (path: string, header: string, next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* replaceTomlVersion(original, header, next, path) + yield* fs.writeFileString(path, rewritten) + }) + +export const bumpNixFile = (path: string, next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* replaceNixVersion(original, next, path) + yield* fs.writeFileString(path, rewritten) + }) + +export const bumpJsonFile = (path: string, next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* replaceJsonVersion(original, next, path) + yield* fs.writeFileString(path, rewritten) + }) + +export const bumpCrateTomls = (next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const names = yield* fs.readDirectory(CRATES_DIR) + for (const name of names) { + const path = `${CRATES_DIR}/${name}/Cargo.toml` + if (yield* fs.exists(path)) { + yield* bumpTomlFile(path, '[package]', next) + } + } + }) + +export const bumpPluginIfPresent = (next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + if (!(yield* fs.exists(PLUGIN_MANIFEST))) return false + yield* bumpJsonFile(PLUGIN_MANIFEST, next) + return true + }) diff --git a/scripts/tools/check-versions.ts b/scripts/tools/check-versions.ts index 274d4b8..8c688d9 100755 --- a/scripts/tools/check-versions.ts +++ b/scripts/tools/check-versions.ts @@ -1,116 +1,85 @@ -#!/usr/bin/env -S deno run --allow-read +#!/usr/bin/env -S deno run --allow-read --allow-env -import { type } from 'arktype' +import { runMain } from '@effect/platform-deno/DenoRuntime' +import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' +import { Console, Effect, FileSystem } from 'effect' -const Semver = type('string') -const isNotFound = (e: unknown): boolean => - e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'NotFound' +import { + CRATES_DIR, + extractJsonVersion, + extractNixVersion, + extractTomlVersion, + FLAKE_NIX, + MANIFEST, + PLUGIN_MANIFEST, + ROOT_MANIFEST, + VersionMismatch, + WORKSPACE_CARGO, +} from '../lib/version-sync.ts' -function extractVersion(content: string, header: string): string | null { - const lines = content.split("\n"); - let inTarget = false; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("[")) { - inTarget = trimmed === header; - } else if (inTarget && trimmed.startsWith("version")) { - const m = /version\s*=\s*"([^"]+)"/.exec(trimmed); - if (m) return Semver.assert(m[1]); - } +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const npmText = yield* fs.readFileString(MANIFEST) + const npmVersion = yield* extractJsonVersion(npmText, MANIFEST) + + const workspaceText = yield* fs.readFileString(WORKSPACE_CARGO) + const workspaceVersion = yield* extractTomlVersion( + workspaceText, + '[workspace.package]', + WORKSPACE_CARGO, + ) + + const mismatches: Array = [] + if (workspaceVersion !== npmVersion) { + mismatches.push(`Cargo.toml workspace ${workspaceVersion} != npm ${npmVersion}`) } - return null; -} -function extractNixVersion(content: string): string | null { - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (trimmed.startsWith('version = "')) { - const m = /version\s*=\s*"([^"]+)"/.exec(trimmed); - if (m) return Semver.assert(m[1]); + const crateNames = yield* fs.readDirectory(CRATES_DIR) + for (const name of crateNames) { + const path = `${CRATES_DIR}/${name}/Cargo.toml` + if (!(yield* fs.exists(path))) continue + const text = yield* fs.readFileString(path) + const crateVersion = yield* extractTomlVersion(text, '[package]', path) + if (crateVersion !== npmVersion) { + mismatches.push(`${path} ${crateVersion} != npm ${npmVersion}`) } } - return null; -} - -function extractJsonVersion(text: string, path: string): string { - const m = /"version"\s*:\s*"([^"]+)"/.exec(text) - if (!m) throw new Error(`no version field in ${path}`) - return Semver.assert(m[1]) -} - -const npmText = await Deno.readTextFile("npm/packages/comment-checker/package.json") -const npmVersion = extractJsonVersion(npmText, "npm/packages/comment-checker/package.json") - -const workspaceContent = await Deno.readTextFile("Cargo.toml"); -const workspaceVersion = extractVersion(workspaceContent, "[workspace.package]"); -if (!workspaceVersion) { - console.error("check-versions: no version found under [workspace.package] in Cargo.toml"); - Deno.exit(1); -} -const mismatches: string[] = []; -if (workspaceVersion !== npmVersion) { - mismatches.push(`Cargo.toml workspace ${workspaceVersion} != npm ${npmVersion}`); -} - -for await (const entry of Deno.readDir("crates")) { - if (!entry.isDirectory) continue; - const path = `crates/${entry.name}/Cargo.toml`; - try { - const content = await Deno.readTextFile(path); - const crateVersion = extractVersion(content, "[package]"); - if (crateVersion && crateVersion !== npmVersion) { - mismatches.push(`${path} ${crateVersion} != npm ${npmVersion}`); + if (yield* fs.exists(FLAKE_NIX)) { + const nixText = yield* fs.readFileString(FLAKE_NIX) + const nixVersion = yield* extractNixVersion(nixText, FLAKE_NIX) + if (nixVersion !== npmVersion) { + mismatches.push(`flake.nix ${nixVersion} != npm ${npmVersion}`) } - } catch (e) { - if (isNotFound(e)) continue; - throw e; } -} -try { - const nixContent = await Deno.readTextFile("flake.nix"); - const nixVersion = extractNixVersion(nixContent); - if (nixVersion && nixVersion !== npmVersion) { - mismatches.push(`flake.nix ${nixVersion} != npm ${npmVersion}`); - } -} catch (e) { - if (!isNotFound(e)) throw e; -} -try { - const rootText = await Deno.readTextFile("package.json") - const rootVersion = extractJsonVersion(rootText, "package.json") - if (rootVersion !== npmVersion) { - mismatches.push(`package.json root ${rootVersion} != npm ${npmVersion}`); + if (yield* fs.exists(ROOT_MANIFEST)) { + const rootText = yield* fs.readFileString(ROOT_MANIFEST) + const rootVersion = yield* extractJsonVersion(rootText, ROOT_MANIFEST) + if (rootVersion !== npmVersion) { + mismatches.push(`package.json root ${rootVersion} != npm ${npmVersion}`) + } } -} catch (e) { - if (!isNotFound(e)) throw e; -} -let pluginChecked = 0; -for (const p of [".claude-plugin/plugin.json"]) { - try { - const content = await Deno.readTextFile(p); - const pluginVersion = extractJsonVersion(content, p) + let pluginChecked = false + if (yield* fs.exists(PLUGIN_MANIFEST)) { + const pluginText = yield* fs.readFileString(PLUGIN_MANIFEST) + const pluginVersion = yield* extractJsonVersion(pluginText, PLUGIN_MANIFEST) if (pluginVersion !== npmVersion) { - mismatches.push(`${p} ${pluginVersion} != npm ${npmVersion}`); + mismatches.push(`${PLUGIN_MANIFEST} ${pluginVersion} != npm ${npmVersion}`) } - pluginChecked++; - } catch (e) { - if (isNotFound(e)) continue; - const isSyntax = e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'SyntaxError' - if (isSyntax) continue; - throw e; + pluginChecked = true + } + + if (mismatches.length > 0) { + return yield* new VersionMismatch({ mismatches }) } -} -if (mismatches.length > 0) { - for (const m of mismatches) console.error(`check-versions: ${m}`); - Deno.exit(1); -} + yield* Console.log( + pluginChecked + ? `check-versions: ok npm=${npmVersion} workspace=${workspaceVersion}` + : `check-versions: ok npm=${npmVersion} workspace=${workspaceVersion} (plugin manifest: none tracked)`, + ) +}) -if (pluginChecked === 0) { - console.log(`check-versions: ok npm=${npmVersion} workspace=${workspaceVersion} (plugin manifest: none tracked)`); -} else { - console.log(`check-versions: ok npm=${npmVersion} workspace=${workspaceVersion}`); -} +runMain(program.pipe(Effect.provide(DenoPlatform))) diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 61ca625..5c19571 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -1,161 +1,98 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-env -import { type } from 'arktype' +import { runMain } from '@effect/platform-deno/DenoRuntime' +import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' +import { Console, Effect, FileSystem, Schema } from 'effect' -const MANIFEST = 'npm/packages/comment-checker/package.json' -const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' -const WORKSPACE_CARGO = 'Cargo.toml' -const ROOT_MANIFEST = 'package.json' -const FLAKE_NIX = 'flake.nix' -const RANK: Record = { patch: 1, minor: 2, major: 3 } +import { + Bump, + bumpCrateTomls, + bumpJsonFile, + bumpNixFile, + bumpPluginIfPresent, + bumpTomlFile, + CHANGELOG, + CHANGESET_DIR, + extractJsonVersion, + FLAKE_NIX, + MANIFEST, + nextVersion, + type ReleaseBump, + ROOT_MANIFEST, + WORKSPACE_CARGO, +} from '../lib/version-sync.ts' -const Semver = type('string') -const isNotFound = (e: unknown): boolean => - e !== null && typeof e === 'object' && 'name' in e && Reflect.get(e, 'name') === 'NotFound' +const RANK: Record = { patch: 1, minor: 2, major: 3 } -function extractJsonVersion(text: string, path: string): string { - const m = /"version"\s*:\s*"([^"]+)"/.exec(text) - if (!m) throw new Error(`no version field in ${path}`) - return Semver.assert(m[1]) -} -function replaceJsonVersion(text: string, next: string, path: string): string { - Semver.assert(next) - if (!/"version"\s*:\s*"[^"]*"/.test(text)) throw new Error(`no version field in ${path}`) - return text.replace(/"version"\s*:\s*"[^"]*"/, `"version": "${next}"`) -} -async function bumpCargoToml(path: string, next: string): Promise { - const original = await Deno.readTextFile(path) - const lines = original.split('\n') - let inTarget = false - let targetHeader: string - if (path === WORKSPACE_CARGO) { - targetHeader = '[workspace.package]' - } else { - targetHeader = '[package]' - } - let bumped = false - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim() - if (trimmed.startsWith('[')) { - inTarget = trimmed === targetHeader - } else if (inTarget && trimmed.startsWith('version')) { - lines[i] = lines[i].replace(/version\s*=\s*"[^"]*"/, `version = "${next}"`) - bumped = true - break - } - } - if (!bumped) { - throw new Error(`bumpCargoToml: no version found under ${targetHeader} in ${path}`) - } - await Deno.writeTextFile(path, lines.join('\n')) -} +const isReleaseBump = (bump: Bump): bump is ReleaseBump => bump !== 'none' -async function bumpNixVersion(path: string, next: string): Promise { - const original = await Deno.readTextFile(path) - const lines = original.split('\n') - let bumped = false - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim() - if (trimmed.startsWith('version = "') && !trimmed.startsWith('rust-version')) { - if (bumped) throw new Error(`bumpNixVersion: multiple version lines in ${path}`) - lines[i] = lines[i].replace(/version\s*=\s*"[^"]*"/, `version = "${next}"`) - bumped = true - } - } - if (!bumped) throw new Error(`bumpNixVersion: no version found in ${path}`) - await Deno.writeTextFile(path, lines.join('\n')) -} +class Intent extends Schema.Class('Intent')({ + path: Schema.String, + bump: Bump, + summary: Schema.String, +}) {} -async function bumpJsonVersion(path: string, next: string): Promise { - const original = await Deno.readTextFile(path) - await Deno.writeTextFile(path, replaceJsonVersion(original, next, path)) -} +const parseIntent = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const body = yield* fs.readFileString(path) + const parts = body.split(/^---$/m) + const rawBump = /:\s*(major|minor|patch|none)/.exec(parts[1] ?? '')?.[1] ?? '' + const bump = yield* Schema.decodeUnknownEffect(Bump)(rawBump) + const summary = (parts[2] ?? '').trim().split('\n').join(' ') + return new Intent({ path, bump, summary }) + }) -async function bumpPluginManifests(next: string): Promise { - let count = 0 - const candidates = ['.claude-plugin/plugin.json'] - for (const p of candidates) { - try { - const content = await Deno.readTextFile(p) - await Deno.writeTextFile(p, replaceJsonVersion(content, next, p)) - count++ - } catch (e) { - if (!isNotFound(e)) throw e - } +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const entries = yield* fs.readDirectory(CHANGESET_DIR) + const pending = entries.filter((name) => name.endsWith('.md') && name !== 'README.md') + if (pending.length === 0) { + yield* Console.log('no change intents; nothing to version') + return } - return count -} -type Intent = { path: string; bump: string; summary: string } -async function parseIntent(path: string): Promise { - const body = await Deno.readTextFile(path) - const parts = body.split(/^---$/m) - const bump = /:\s*(major|minor|patch|none)/.exec(parts[1] ?? '')?.[1] ?? '' - const summary = (parts[2] ?? '').trim().split('\n').join(' ') - return { path, bump, summary } -} - -function nextVersion(version: string, bump: string): string { - const [major, minor, patch] = version.split('.').map(Number) - if (bump === 'major') return `${major + 1}.0.0` - if (bump === 'minor') return `${major}.${minor + 1}.0` - return `${major}.${minor}.${patch + 1}` -} - -const pending: string[] = [] -for await (const entry of Deno.readDir('./.changeset')) { - if (entry.name.endsWith('.md') && entry.name !== 'README.md') { - pending.push(entry.name) + const intents = yield* Effect.all(pending.map((name) => parseIntent(`${CHANGESET_DIR}/${name}`))) + const releases = intents.filter((i) => isReleaseBump(i.bump)) + if (releases.length === 0) { + for (const i of intents) yield* fs.remove(i.path) + yield* Console.log('only none intents; consumed without version bump') + return } -} -if (pending.length === 0) { - console.log('no change intents; nothing to version') - Deno.exit(0) -} -const intents = await Promise.all( - pending.map((name) => parseIntent(`.changeset/${name}`)), -) -const releases = intents.filter((i) => i.bump !== 'none') -if (releases.length === 0) { - for (const i of intents) await Deno.remove(i.path) - console.log('only none intents; consumed without version bump') - Deno.exit(0) -} + const chosen = releases.sort((a, b) => { + const aBump = a.bump as ReleaseBump + const bBump = b.bump as ReleaseBump + return RANK[bBump] - RANK[aBump] + })[0] + const bump = chosen.bump as ReleaseBump + const summary = releases.map((i) => ` - ${i.summary}`).join('\n') + const manifestText = yield* fs.readFileString(MANIFEST) + const version = yield* extractJsonVersion(manifestText, MANIFEST) + const next = yield* nextVersion(version, bump) -const bump = releases.sort((a, b) => RANK[b.bump] - RANK[a.bump])[0].bump -const summary = releases.map((i) => ` - ${i.summary}`).join('\n') -const manifestText = await Deno.readTextFile(MANIFEST) -const version = extractJsonVersion(manifestText, MANIFEST) -const next = nextVersion(version, bump) - -await Deno.writeTextFile(MANIFEST, replaceJsonVersion(manifestText, next, MANIFEST)) -await bumpCargoToml(WORKSPACE_CARGO, next) -for await (const entry of Deno.readDir('crates')) { - if (!entry.isDirectory) continue - const path = `crates/${entry.name}/Cargo.toml` - try { - await bumpCargoToml(path, next) - } catch (e) { - if (isNotFound(e)) continue - throw e + yield* bumpJsonFile(MANIFEST, next) + yield* bumpTomlFile(WORKSPACE_CARGO, '[workspace.package]', next) + yield* bumpCrateTomls(next) + yield* bumpNixFile(FLAKE_NIX, next) + yield* bumpJsonFile(ROOT_MANIFEST, next) + const pluginBumped = yield* bumpPluginIfPresent(next) + if (!pluginBumped) { + yield* Console.log('plugin manifest: none tracked — skipped') } -} -await bumpNixVersion(FLAKE_NIX, next) -await bumpJsonVersion(ROOT_MANIFEST, next) -const pluginCount = await bumpPluginManifests(next) -if (pluginCount === 0) { - console.log('plugin manifest: none tracked — skipped') -} -let changelog = '# Changelog\n' -try { - changelog = await Deno.readTextFile(CHANGELOG) -} catch { - // empty -} -changelog = `${changelog.trimEnd()}\n\n## ${next}\n\n${summary}\n` -await Deno.writeTextFile(CHANGELOG, changelog) + const changelog = yield* fs.exists(CHANGELOG).pipe( + Effect.flatMap((exists) => + exists ? fs.readFileString(CHANGELOG) : Effect.succeed('# Changelog\n') + ), + ) + yield* fs.writeFileString( + CHANGELOG, + `${changelog.trimEnd()}\n\n## ${next}\n\n${summary}\n`, + ) + + for (const i of intents) yield* fs.remove(i.path) + yield* Console.log(`versioned packages to ${next}`) +}) -for (const i of intents) await Deno.remove(i.path) -console.log(`versioned packages to ${next}`) +runMain(program.pipe(Effect.provide(DenoPlatform))) From eacc44a1339647642f37783ff7708483baae336a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 20:56:55 +0000 Subject: [PATCH 5/7] refactor(release): parse Cargo.toml with @std/toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the line-scanner that pretended to parse TOML. extractTomlVersion and replaceTomlVersion now parse with @std/toml, locate workspace.package / package via Schema.Record, decode version with Schema.decodeUnknownEffect(Semver), mutate the table, and stringifyToml. Nix/JSON stay text; they are not TOML. Probe 0.3.0 → 0.3.1: cargo test accepts the stringified manifests. --- scripts/deno.jsonc | 1 + scripts/deno.lock | 12 +++++ scripts/lib/version-sync.ts | 93 ++++++++++++++++++++++++------------- 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc index 3577889..4d56220 100644 --- a/scripts/deno.jsonc +++ b/scripts/deno.jsonc @@ -4,6 +4,7 @@ "@libs/diff": "jsr:@libs/diff@4.0.0", "@std/cli": "jsr:@std/cli@1.0.15", "@std/path": "jsr:@std/path@1.1.6", + "@std/toml": "jsr:@std/toml@1.0.11", "@std/yaml": "jsr:@std/yaml@1.0.4", "arktype": "npm:arktype@2.2.3", "effect": "npm:effect@4.0.0-rc.108" diff --git a/scripts/deno.lock b/scripts/deno.lock index 20c6752..36b0092 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -3,8 +3,10 @@ "specifiers": { "jsr:@libs/diff@4.0.0": "4.0.0", "jsr:@std/cli@1.0.15": "1.0.15", + "jsr:@std/collections@^1.1.3": "1.3.0", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@1.1.6": "1.1.6", + "jsr:@std/toml@1.0.11": "1.0.11", "jsr:@std/yaml@1.0.4": "1.0.4", "npm:@effect/platform-deno@4.0.0-rc.108": "4.0.0-rc.108_effect@4.0.0-rc.108", "npm:arktype@2.2.3": "2.2.3", @@ -17,6 +19,9 @@ "@std/cli@1.0.15": { "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" }, + "@std/collections@1.3.0": { + "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, @@ -26,6 +31,12 @@ "jsr:@std/internal" ] }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, "@std/yaml@1.0.4": { "integrity": "f1e38fa5e110ff8550e938715e52c33347676dd00a1db858e092dd21a9df9941" } @@ -274,6 +285,7 @@ "jsr:@libs/diff@4.0.0", "jsr:@std/cli@1.0.15", "jsr:@std/path@1.1.6", + "jsr:@std/toml@1.0.11", "jsr:@std/yaml@1.0.4", "npm:@effect/platform-deno@4.0.0-rc.108", "npm:arktype@2.2.3", diff --git a/scripts/lib/version-sync.ts b/scripts/lib/version-sync.ts index 9426aa0..c6ea8d7 100644 --- a/scripts/lib/version-sync.ts +++ b/scripts/lib/version-sync.ts @@ -1,3 +1,4 @@ +import { parse as parseToml, stringify as stringifyToml } from '@std/toml' import { Effect, FileSystem, Schema } from 'effect' export const Semver = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)) @@ -35,6 +36,53 @@ export class VersionMismatch extends Schema.TaggedError()('Vers } } +export class TomlParseFailed extends Schema.TaggedError()('TomlParseFailed', { + path: Schema.String, + detail: Schema.String, +}) { + override get message(): string { + return `TOML parse failed for ${this.path}: ${this.detail}` + } +} + +const TomlTable = Schema.Record(Schema.String, Schema.Unknown) +type TomlTable = typeof TomlTable.Type + +const tableKeys = (header: string): ReadonlyArray | undefined => { + if (header === '[workspace.package]') return ['workspace', 'package'] + if (header === '[package]') return ['package'] + return undefined +} + +const decodeToml = (text: string, path: string) => + Effect.try({ + try: () => parseToml(text), + catch: (cause) => new TomlParseFailed({ path, detail: String(cause) }), + }) + +const locateVersionTable = ( + root: unknown, + header: string, + path: string, +) => + Effect.gen(function* () { + const keys = tableKeys(header) + if (keys === undefined) return yield* new MissingVersion({ path, header }) + let cur: unknown = root + for (const key of keys) { + if (!Schema.is(TomlTable)(cur) || !(key in cur)) { + return yield* new MissingVersion({ path, header }) + } + cur = cur[key] + } + if (!Schema.is(TomlTable)(cur) || !('version' in cur)) { + return yield* new MissingVersion({ path, header }) + } + const version = yield* Schema.decodeUnknownEffect(Semver)(cur.version) + + return { table: cur, version } + }) + export const MANIFEST = 'npm/packages/comment-checker/package.json' export const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' export const WORKSPACE_CARGO = 'Cargo.toml' @@ -46,8 +94,8 @@ export const CHANGESET_DIR = './.changeset' const JSON_VERSION = /"version"\s*:\s*"([^"]+)"/ const JSON_VERSION_ANY = /"version"\s*:\s*"[^"]*"/ -const TOML_VERSION = /version\s*=\s*"([^"]+)"/ -const TOML_VERSION_ANY = /version\s*=\s*"[^"]*"/ +const NIX_VERSION = /version\s*=\s*"([^"]+)"/ +const NIX_VERSION_ANY = /version\s*=\s*"[^"]*"/ export const decodeSemver = (raw: string) => Schema.decodeUnknownEffect(Semver)(raw) export const extractJsonVersion = (text: string, path: string) => @@ -65,38 +113,17 @@ export const replaceJsonVersion = (text: string, next: Semver, path: string) => export const extractTomlVersion = (text: string, header: string, path: string) => Effect.gen(function* () { - const lines = text.split('\n') - let inTarget = false - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('[')) { - inTarget = trimmed === header - } else if (inTarget && trimmed.startsWith('version')) { - const m = TOML_VERSION.exec(trimmed) - if (!m) return yield* new MissingVersion({ path, header }) - return yield* decodeSemver(m[1]) - } - } - return yield* new MissingVersion({ path, header }) + const parsed = yield* decodeToml(text, path) + const located = yield* locateVersionTable(parsed, header, path) + return located.version }) export const replaceTomlVersion = (text: string, header: string, next: Semver, path: string) => Effect.gen(function* () { - const lines = text.split('\n') - let inTarget = false - let bumped = false - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim() - if (trimmed.startsWith('[')) { - inTarget = trimmed === header - } else if (inTarget && trimmed.startsWith('version')) { - lines[i] = lines[i].replace(TOML_VERSION_ANY, `version = "${next}"`) - bumped = true - break - } - } - if (!bumped) return yield* new MissingVersion({ path, header }) - return lines.join('\n') + const parsed = yield* decodeToml(text, path) + const located = yield* locateVersionTable(parsed, header, path) + Object.assign(located.table, { version: next }) + return stringifyToml(parsed) }) export const extractNixVersion = (text: string, path: string) => @@ -105,7 +132,8 @@ export const extractNixVersion = (text: string, path: string) => for (const line of text.split('\n')) { const trimmed = line.trim() if (trimmed.startsWith('version = "')) { - const m = TOML_VERSION.exec(trimmed) + const m = NIX_VERSION.exec(trimmed) + if (!m) continue if (found !== undefined) return yield* new AmbiguousNixVersion({ path }) found = m[1] @@ -123,7 +151,8 @@ export const replaceNixVersion = (text: string, next: Semver, path: string) => const trimmed = lines[i].trim() if (trimmed.startsWith('version = "')) { if (bumped) return yield* new AmbiguousNixVersion({ path }) - lines[i] = lines[i].replace(TOML_VERSION_ANY, `version = "${next}"`) + lines[i] = lines[i].replace(NIX_VERSION_ANY, `version = "${next}"`) + bumped = true } } From b78a8eed7ad3955ba1f7e133fdb65a15a74893a2 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 21:19:28 +0000 Subject: [PATCH 6/7] refactor(release): split version core from filesystem adapter version-sync.ts no longer imports FileSystem. Semver, TomlHeader literals, PackageTable (Struct { version: Semver }), extract/replace, and nextVersion are data-in/data-out. Filesystem bump lives in version-files.ts, consumed only by the CLI interpretation edges. Fixes I1 (core imported the filesystem) and I5 (untyped header strings; leaf table now Schema.Struct version: Semver). --- scripts/lib/version-files.ts | 76 ++++++++++++++++++++++++++ scripts/lib/version-sync.ts | 92 ++++++++------------------------ scripts/tools/release-version.ts | 16 +----- 3 files changed, 100 insertions(+), 84 deletions(-) create mode 100644 scripts/lib/version-files.ts diff --git a/scripts/lib/version-files.ts b/scripts/lib/version-files.ts new file mode 100644 index 0000000..4b05a1b --- /dev/null +++ b/scripts/lib/version-files.ts @@ -0,0 +1,76 @@ +import { Effect, FileSystem } from 'effect' +import { + CRATES_DIR, + extractJsonVersion, + extractNixVersion, + extractTomlVersion, + FLAKE_NIX, + MANIFEST, + PLUGIN_MANIFEST, + replaceJsonVersion, + replaceNixVersion, + replaceTomlVersion, + ROOT_MANIFEST, + type Semver, + type TomlHeader, + WORKSPACE_CARGO, +} from './version-sync.ts' + +const rewrite = ( + path: string, + next: (text: string) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* next(original) + yield* fs.writeFileString(path, rewritten) + }) + +export const bumpTomlFile = (path: string, header: TomlHeader, next: Semver) => + rewrite(path, (text) => replaceTomlVersion(text, header, next, path)) + +export const bumpNixFile = (path: string, next: Semver) => + rewrite(path, (text) => replaceNixVersion(text, next, path)) + +export const bumpJsonFile = (path: string, next: Semver) => + rewrite(path, (text) => replaceJsonVersion(text, next, path)) + +export const bumpCrateTomls = (next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const names = yield* fs.readDirectory(CRATES_DIR) + for (const name of names) { + const path = `${CRATES_DIR}/${name}/Cargo.toml` + if (yield* fs.exists(path)) { + yield* bumpTomlFile(path, '[package]', next) + } + } + }) + +export const bumpPluginIfPresent = (next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + if (!(yield* fs.exists(PLUGIN_MANIFEST))) return false + yield* bumpJsonFile(PLUGIN_MANIFEST, next) + return true + }) + +export const bumpAllSurfaces = (next: Semver) => + Effect.gen(function* () { + yield* bumpJsonFile(MANIFEST, next) + yield* bumpTomlFile(WORKSPACE_CARGO, '[workspace.package]', next) + yield* bumpCrateTomls(next) + yield* bumpNixFile(FLAKE_NIX, next) + yield* bumpJsonFile(ROOT_MANIFEST, next) + return yield* bumpPluginIfPresent(next) + }) + +export const readSurfaceVersion = (path: string, kind: 'json' | 'nix' | TomlHeader) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const text = yield* fs.readFileString(path) + if (kind === 'json') return yield* extractJsonVersion(text, path) + if (kind === 'nix') return yield* extractNixVersion(text, path) + return yield* extractTomlVersion(text, kind, path) + }) diff --git a/scripts/lib/version-sync.ts b/scripts/lib/version-sync.ts index c6ea8d7..9b44092 100644 --- a/scripts/lib/version-sync.ts +++ b/scripts/lib/version-sync.ts @@ -1,5 +1,5 @@ import { parse as parseToml, stringify as stringifyToml } from '@std/toml' -import { Effect, FileSystem, Schema } from 'effect' +import { Effect, Schema } from 'effect' export const Semver = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)) export type Semver = typeof Semver.Type @@ -8,6 +8,9 @@ export const Bump = Schema.Literals(['major', 'minor', 'patch', 'none']) export type Bump = typeof Bump.Type export type ReleaseBump = Exclude +export const TomlHeader = Schema.Literals(['[workspace.package]', '[package]']) +export type TomlHeader = typeof TomlHeader.Type + export class MissingVersion extends Schema.TaggedError()('MissingVersion', { path: Schema.String, header: Schema.optionalKey(Schema.String), @@ -45,13 +48,12 @@ export class TomlParseFailed extends Schema.TaggedError()('Toml } } +const PackageTable = Schema.Struct({ version: Semver }) const TomlTable = Schema.Record(Schema.String, Schema.Unknown) -type TomlTable = typeof TomlTable.Type -const tableKeys = (header: string): ReadonlyArray | undefined => { - if (header === '[workspace.package]') return ['workspace', 'package'] - if (header === '[package]') return ['package'] - return undefined +const TABLE_KEYS: { readonly [H in TomlHeader]: ReadonlyArray } = { + '[workspace.package]': ['workspace', 'package'], + '[package]': ['package'], } const decodeToml = (text: string, path: string) => @@ -60,27 +62,19 @@ const decodeToml = (text: string, path: string) => catch: (cause) => new TomlParseFailed({ path, detail: String(cause) }), }) -const locateVersionTable = ( - root: unknown, - header: string, - path: string, -) => +const locateVersionTable = (root: unknown, header: TomlHeader, path: string) => Effect.gen(function* () { - const keys = tableKeys(header) - if (keys === undefined) return yield* new MissingVersion({ path, header }) let cur: unknown = root - for (const key of keys) { + for (const key of TABLE_KEYS[header]) { if (!Schema.is(TomlTable)(cur) || !(key in cur)) { return yield* new MissingVersion({ path, header }) } cur = cur[key] } - if (!Schema.is(TomlTable)(cur) || !('version' in cur)) { - return yield* new MissingVersion({ path, header }) - } - const version = yield* Schema.decodeUnknownEffect(Semver)(cur.version) - - return { table: cur, version } + const table = yield* Schema.decodeUnknownEffect(PackageTable)(cur).pipe( + Effect.mapError(() => new MissingVersion({ path, header })), + ) + return { holder: cur, version: table.version } }) export const MANIFEST = 'npm/packages/comment-checker/package.json' @@ -98,6 +92,7 @@ const NIX_VERSION = /version\s*=\s*"([^"]+)"/ const NIX_VERSION_ANY = /version\s*=\s*"[^"]*"/ export const decodeSemver = (raw: string) => Schema.decodeUnknownEffect(Semver)(raw) + export const extractJsonVersion = (text: string, path: string) => Effect.gen(function* () { const m = JSON_VERSION.exec(text) @@ -111,18 +106,21 @@ export const replaceJsonVersion = (text: string, next: Semver, path: string) => return text.replace(JSON_VERSION_ANY, `"version": "${next}"`) }) -export const extractTomlVersion = (text: string, header: string, path: string) => +export const extractTomlVersion = (text: string, header: TomlHeader, path: string) => Effect.gen(function* () { const parsed = yield* decodeToml(text, path) const located = yield* locateVersionTable(parsed, header, path) return located.version }) -export const replaceTomlVersion = (text: string, header: string, next: Semver, path: string) => +export const replaceTomlVersion = (text: string, header: TomlHeader, next: Semver, path: string) => Effect.gen(function* () { const parsed = yield* decodeToml(text, path) const located = yield* locateVersionTable(parsed, header, path) - Object.assign(located.table, { version: next }) + if (!Schema.is(TomlTable)(located.holder)) { + return yield* new MissingVersion({ path, header }) + } + Object.assign(located.holder, { version: next }) return stringifyToml(parsed) }) @@ -133,7 +131,6 @@ export const extractNixVersion = (text: string, path: string) => const trimmed = line.trim() if (trimmed.startsWith('version = "')) { const m = NIX_VERSION.exec(trimmed) - if (!m) continue if (found !== undefined) return yield* new AmbiguousNixVersion({ path }) found = m[1] @@ -152,7 +149,6 @@ export const replaceNixVersion = (text: string, next: Semver, path: string) => if (trimmed.startsWith('version = "')) { if (bumped) return yield* new AmbiguousNixVersion({ path }) lines[i] = lines[i].replace(NIX_VERSION_ANY, `version = "${next}"`) - bumped = true } } @@ -160,7 +156,7 @@ export const replaceNixVersion = (text: string, next: Semver, path: string) => return lines.join('\n') }) -export const nextVersion = (version: Semver, bump: Exclude) => { +export const nextVersion = (version: Semver, bump: ReleaseBump) => { const [major, minor, patch] = version.split('.').map(Number) const raw = bump === 'major' ? `${major + 1}.0.0` @@ -169,47 +165,3 @@ export const nextVersion = (version: Semver, bump: Exclude) => { : `${major}.${minor}.${patch + 1}` return decodeSemver(raw) } - -export const bumpTomlFile = (path: string, header: string, next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const original = yield* fs.readFileString(path) - const rewritten = yield* replaceTomlVersion(original, header, next, path) - yield* fs.writeFileString(path, rewritten) - }) - -export const bumpNixFile = (path: string, next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const original = yield* fs.readFileString(path) - const rewritten = yield* replaceNixVersion(original, next, path) - yield* fs.writeFileString(path, rewritten) - }) - -export const bumpJsonFile = (path: string, next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const original = yield* fs.readFileString(path) - const rewritten = yield* replaceJsonVersion(original, next, path) - yield* fs.writeFileString(path, rewritten) - }) - -export const bumpCrateTomls = (next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const names = yield* fs.readDirectory(CRATES_DIR) - for (const name of names) { - const path = `${CRATES_DIR}/${name}/Cargo.toml` - if (yield* fs.exists(path)) { - yield* bumpTomlFile(path, '[package]', next) - } - } - }) - -export const bumpPluginIfPresent = (next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - if (!(yield* fs.exists(PLUGIN_MANIFEST))) return false - yield* bumpJsonFile(PLUGIN_MANIFEST, next) - return true - }) diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 5c19571..f58ba50 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -6,21 +6,14 @@ import { Console, Effect, FileSystem, Schema } from 'effect' import { Bump, - bumpCrateTomls, - bumpJsonFile, - bumpNixFile, - bumpPluginIfPresent, - bumpTomlFile, CHANGELOG, CHANGESET_DIR, extractJsonVersion, - FLAKE_NIX, MANIFEST, nextVersion, type ReleaseBump, - ROOT_MANIFEST, - WORKSPACE_CARGO, } from '../lib/version-sync.ts' +import { bumpAllSurfaces } from '../lib/version-files.ts' const RANK: Record = { patch: 1, minor: 2, major: 3 } @@ -71,12 +64,7 @@ const program = Effect.gen(function* () { const version = yield* extractJsonVersion(manifestText, MANIFEST) const next = yield* nextVersion(version, bump) - yield* bumpJsonFile(MANIFEST, next) - yield* bumpTomlFile(WORKSPACE_CARGO, '[workspace.package]', next) - yield* bumpCrateTomls(next) - yield* bumpNixFile(FLAKE_NIX, next) - yield* bumpJsonFile(ROOT_MANIFEST, next) - const pluginBumped = yield* bumpPluginIfPresent(next) + const pluginBumped = yield* bumpAllSurfaces(next) if (!pluginBumped) { yield* Console.log('plugin manifest: none tracked — skipped') } From 7f6e9c5379c1e6d772c8c5553952ec8d0f0c45d1 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 21:27:21 +0000 Subject: [PATCH 7/7] refactor(release): decode versions with Schema, toml, and yaml JSON goes through Schema.fromJsonString. Cargo.toml is parse/stringify via @std/toml and Schema.Struct workspace.package / package. Changesets are @std/yaml frontmatter decoded as Record. Nix keeps a single version-binding grammar (exactly one `version = ""` line). Filesystem stays in version-files.ts. CLIs only runMain + inventory. No JSON.parse, no instanceof, no `as ReleaseBump`. --- scripts/lib/version-files.ts | 108 ++++++++++++------- scripts/lib/version-sync.ts | 179 +++++++++++++++++++------------ scripts/tools/check-versions.ts | 78 +------------- scripts/tools/release-version.ts | 47 +++----- 4 files changed, 199 insertions(+), 213 deletions(-) diff --git a/scripts/lib/version-files.ts b/scripts/lib/version-files.ts index 4b05a1b..ed9f90a 100644 --- a/scripts/lib/version-files.ts +++ b/scripts/lib/version-files.ts @@ -12,65 +12,99 @@ import { replaceTomlVersion, ROOT_MANIFEST, type Semver, - type TomlHeader, + VersionMismatch, WORKSPACE_CARGO, } from './version-sync.ts' -const rewrite = ( +const rewriteJson = (path: string, next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* replaceJsonVersion(original, next, path) + yield* fs.writeFileString(path, rewritten) + }) + +const rewriteToml = ( path: string, - next: (text: string) => Effect.Effect, + header: '[workspace.package]' | '[package]', + next: Semver, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const original = yield* fs.readFileString(path) - const rewritten = yield* next(original) + const rewritten = yield* replaceTomlVersion(original, header, next, path) yield* fs.writeFileString(path, rewritten) }) -export const bumpTomlFile = (path: string, header: TomlHeader, next: Semver) => - rewrite(path, (text) => replaceTomlVersion(text, header, next, path)) - -export const bumpNixFile = (path: string, next: Semver) => - rewrite(path, (text) => replaceNixVersion(text, next, path)) - -export const bumpJsonFile = (path: string, next: Semver) => - rewrite(path, (text) => replaceJsonVersion(text, next, path)) +const rewriteNix = (path: string, next: Semver) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const original = yield* fs.readFileString(path) + const rewritten = yield* replaceNixVersion(original, next, path) + yield* fs.writeFileString(path, rewritten) + }) -export const bumpCrateTomls = (next: Semver) => +export const bumpAllSurfaces = (next: Semver) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem + yield* rewriteJson(MANIFEST, next) + yield* rewriteToml(WORKSPACE_CARGO, '[workspace.package]', next) const names = yield* fs.readDirectory(CRATES_DIR) for (const name of names) { const path = `${CRATES_DIR}/${name}/Cargo.toml` - if (yield* fs.exists(path)) { - yield* bumpTomlFile(path, '[package]', next) - } + if (yield* fs.exists(path)) yield* rewriteToml(path, '[package]', next) } - }) - -export const bumpPluginIfPresent = (next: Semver) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem + yield* rewriteNix(FLAKE_NIX, next) + yield* rewriteJson(ROOT_MANIFEST, next) if (!(yield* fs.exists(PLUGIN_MANIFEST))) return false - yield* bumpJsonFile(PLUGIN_MANIFEST, next) + yield* rewriteJson(PLUGIN_MANIFEST, next) return true }) -export const bumpAllSurfaces = (next: Semver) => - Effect.gen(function* () { - yield* bumpJsonFile(MANIFEST, next) - yield* bumpTomlFile(WORKSPACE_CARGO, '[workspace.package]', next) - yield* bumpCrateTomls(next) - yield* bumpNixFile(FLAKE_NIX, next) - yield* bumpJsonFile(ROOT_MANIFEST, next) - return yield* bumpPluginIfPresent(next) - }) - -export const readSurfaceVersion = (path: string, kind: 'json' | 'nix' | TomlHeader) => +export const checkAllSurfaces = () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const text = yield* fs.readFileString(path) - if (kind === 'json') return yield* extractJsonVersion(text, path) - if (kind === 'nix') return yield* extractNixVersion(text, path) - return yield* extractTomlVersion(text, kind, path) + const npmText = yield* fs.readFileString(MANIFEST) + const expected = yield* extractJsonVersion(npmText, MANIFEST) + const diffs: Array<{ path: string; found: Semver }> = [] + + const check = (path: string, found: Semver) => { + if (found !== expected) diffs.push({ path, found }) + } + + const workspaceText = yield* fs.readFileString(WORKSPACE_CARGO) + const workspaceVersion = yield* extractTomlVersion( + workspaceText, + '[workspace.package]', + WORKSPACE_CARGO, + ) + check(WORKSPACE_CARGO, workspaceVersion) + + const names = yield* fs.readDirectory(CRATES_DIR) + for (const name of names) { + const path = `${CRATES_DIR}/${name}/Cargo.toml` + if (!(yield* fs.exists(path))) continue + const text = yield* fs.readFileString(path) + check(path, yield* extractTomlVersion(text, '[package]', path)) + } + + if (yield* fs.exists(FLAKE_NIX)) { + const text = yield* fs.readFileString(FLAKE_NIX) + check(FLAKE_NIX, yield* extractNixVersion(text, FLAKE_NIX)) + } + + if (yield* fs.exists(ROOT_MANIFEST)) { + const text = yield* fs.readFileString(ROOT_MANIFEST) + check(ROOT_MANIFEST, yield* extractJsonVersion(text, ROOT_MANIFEST)) + } + + let pluginChecked = false + if (yield* fs.exists(PLUGIN_MANIFEST)) { + const text = yield* fs.readFileString(PLUGIN_MANIFEST) + check(PLUGIN_MANIFEST, yield* extractJsonVersion(text, PLUGIN_MANIFEST)) + pluginChecked = true + } + + if (diffs.length > 0) return yield* new VersionMismatch({ expected, diffs }) + return { expected, workspaceVersion, pluginChecked } }) diff --git a/scripts/lib/version-sync.ts b/scripts/lib/version-sync.ts index 9b44092..b57dbce 100644 --- a/scripts/lib/version-sync.ts +++ b/scripts/lib/version-sync.ts @@ -1,4 +1,5 @@ import { parse as parseToml, stringify as stringifyToml } from '@std/toml' +import { parse as parseYaml } from '@std/yaml' import { Effect, Schema } from 'effect' export const Semver = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)) @@ -6,7 +7,9 @@ export type Semver = typeof Semver.Type export const Bump = Schema.Literals(['major', 'minor', 'patch', 'none']) export type Bump = typeof Bump.Type -export type ReleaseBump = Exclude + +export const ReleaseBump = Schema.Literals(['major', 'minor', 'patch']) +export type ReleaseBump = typeof ReleaseBump.Type export const TomlHeader = Schema.Literals(['[workspace.package]', '[package]']) export type TomlHeader = typeof TomlHeader.Type @@ -32,10 +35,16 @@ export class AmbiguousNixVersion } export class VersionMismatch extends Schema.TaggedError()('VersionMismatch', { - mismatches: Schema.Array(Schema.String), + expected: Semver, + diffs: Schema.Array(Schema.Struct({ + path: Schema.String, + found: Semver, + })), }) { override get message(): string { - return this.mismatches.map((m) => `check-versions: ${m}`).join('\n') + return this.diffs + .map((d) => `check-versions: ${d.path} ${d.found} != npm ${this.expected}`) + .join('\n') } } @@ -48,34 +57,40 @@ export class TomlParseFailed extends Schema.TaggedError()('Toml } } -const PackageTable = Schema.Struct({ version: Semver }) -const TomlTable = Schema.Record(Schema.String, Schema.Unknown) +export class YamlParseFailed extends Schema.TaggedError()('YamlParseFailed', { + path: Schema.String, + detail: Schema.String, +}) { + override get message(): string { + return `YAML parse failed for ${this.path}: ${this.detail}` + } +} -const TABLE_KEYS: { readonly [H in TomlHeader]: ReadonlyArray } = { - '[workspace.package]': ['workspace', 'package'], - '[package]': ['package'], +export class ChangesetParseFailed + extends Schema.TaggedError()('ChangesetParseFailed', { + path: Schema.String, + }) { + override get message(): string { + return `changeset is not YAML frontmatter: ${this.path}` + } } -const decodeToml = (text: string, path: string) => - Effect.try({ - try: () => parseToml(text), - catch: (cause) => new TomlParseFailed({ path, detail: String(cause) }), - }) - -const locateVersionTable = (root: unknown, header: TomlHeader, path: string) => - Effect.gen(function* () { - let cur: unknown = root - for (const key of TABLE_KEYS[header]) { - if (!Schema.is(TomlTable)(cur) || !(key in cur)) { - return yield* new MissingVersion({ path, header }) - } - cur = cur[key] - } - const table = yield* Schema.decodeUnknownEffect(PackageTable)(cur).pipe( - Effect.mapError(() => new MissingVersion({ path, header })), - ) - return { holder: cur, version: table.version } - }) +const Versioned = Schema.Struct({ version: Semver }) +const WorkspaceToml = Schema.Struct({ + workspace: Schema.Struct({ + package: Versioned, + }), +}) +const PackageToml = Schema.Struct({ + package: Versioned, +}) +const JsonDocument = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown), { + space: 2, +}) +const ChangesetFrontmatter = Schema.Record(Schema.String, Bump) +const nixVersionBindings = ( + text: string, +) => [...text.matchAll(/^\s*version\s*=\s*"(\d+\.\d+\.\d+)"\s*;?\s*$/gm)] export const MANIFEST = 'npm/packages/comment-checker/package.json' export const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' @@ -86,74 +101,78 @@ export const PLUGIN_MANIFEST = '.claude-plugin/plugin.json' export const CRATES_DIR = 'crates' export const CHANGESET_DIR = './.changeset' -const JSON_VERSION = /"version"\s*:\s*"([^"]+)"/ -const JSON_VERSION_ANY = /"version"\s*:\s*"[^"]*"/ -const NIX_VERSION = /version\s*=\s*"([^"]+)"/ -const NIX_VERSION_ANY = /version\s*=\s*"[^"]*"/ +export const RANK: { readonly [K in ReleaseBump]: number } = { patch: 1, minor: 2, major: 3 } -export const decodeSemver = (raw: string) => Schema.decodeUnknownEffect(Semver)(raw) +const decodeToml = (text: string, path: string) => + Effect.try({ + try: () => parseToml(text), + catch: (cause) => new TomlParseFailed({ path, detail: String(cause) }), + }) export const extractJsonVersion = (text: string, path: string) => Effect.gen(function* () { - const m = JSON_VERSION.exec(text) - if (!m) return yield* new MissingVersion({ path }) - return yield* decodeSemver(m[1]) + const doc = yield* Schema.decodeUnknownEffect(JsonDocument)(text).pipe( + Effect.mapError(() => new MissingVersion({ path })), + ) + const row = yield* Schema.decodeUnknownEffect(Versioned)(doc).pipe( + Effect.mapError(() => new MissingVersion({ path })), + ) + return row.version }) export const replaceJsonVersion = (text: string, next: Semver, path: string) => Effect.gen(function* () { - if (!JSON_VERSION_ANY.test(text)) return yield* new MissingVersion({ path }) - return text.replace(JSON_VERSION_ANY, `"version": "${next}"`) + const doc = yield* Schema.decodeUnknownEffect(JsonDocument)(text).pipe( + Effect.mapError(() => new MissingVersion({ path })), + ) + yield* Schema.decodeUnknownEffect(Versioned)(doc).pipe( + Effect.mapError(() => new MissingVersion({ path })), + ) + const encoded = yield* Schema.encodeUnknownEffect(JsonDocument)({ ...doc, version: next }) + return encoded.endsWith('\n') ? encoded : `${encoded}\n` }) export const extractTomlVersion = (text: string, header: TomlHeader, path: string) => Effect.gen(function* () { const parsed = yield* decodeToml(text, path) - const located = yield* locateVersionTable(parsed, header, path) - return located.version + if (header === '[workspace.package]') { + if (!Schema.is(WorkspaceToml)(parsed)) return yield* new MissingVersion({ path, header }) + return parsed.workspace.package.version + } + if (!Schema.is(PackageToml)(parsed)) return yield* new MissingVersion({ path, header }) + return parsed.package.version }) export const replaceTomlVersion = (text: string, header: TomlHeader, next: Semver, path: string) => Effect.gen(function* () { const parsed = yield* decodeToml(text, path) - const located = yield* locateVersionTable(parsed, header, path) - if (!Schema.is(TomlTable)(located.holder)) { - return yield* new MissingVersion({ path, header }) + if (header === '[workspace.package]') { + if (!Schema.is(WorkspaceToml)(parsed)) return yield* new MissingVersion({ path, header }) + Object.assign(parsed.workspace.package, { version: next }) + return stringifyToml(parsed) } - Object.assign(located.holder, { version: next }) + if (!Schema.is(PackageToml)(parsed)) return yield* new MissingVersion({ path, header }) + Object.assign(parsed.package, { version: next }) return stringifyToml(parsed) }) - export const extractNixVersion = (text: string, path: string) => Effect.gen(function* () { - let found: string | undefined - for (const line of text.split('\n')) { - const trimmed = line.trim() - if (trimmed.startsWith('version = "')) { - const m = NIX_VERSION.exec(trimmed) - if (!m) continue - if (found !== undefined) return yield* new AmbiguousNixVersion({ path }) - found = m[1] - } - } - if (found === undefined) return yield* new MissingVersion({ path }) - return yield* decodeSemver(found) + const hits = nixVersionBindings(text) + if (hits.length === 0) return yield* new MissingVersion({ path }) + if (hits.length > 1) return yield* new AmbiguousNixVersion({ path }) + return yield* Schema.decodeUnknownEffect(Semver)(hits[0][1]) }) export const replaceNixVersion = (text: string, next: Semver, path: string) => Effect.gen(function* () { - const lines = text.split('\n') - let bumped = false - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim() - if (trimmed.startsWith('version = "')) { - if (bumped) return yield* new AmbiguousNixVersion({ path }) - lines[i] = lines[i].replace(NIX_VERSION_ANY, `version = "${next}"`) - bumped = true - } - } - if (!bumped) return yield* new MissingVersion({ path }) - return lines.join('\n') + const current = yield* extractNixVersion(text, path) + const hits = nixVersionBindings(text) + const hit = hits[0] + const index = hit.index + if (index === undefined) return yield* new MissingVersion({ path }) + return `${text.slice(0, index)}${hit[0].replace(current, next)}${ + text.slice(index + hit[0].length) + }` }) export const nextVersion = (version: Semver, bump: ReleaseBump) => { @@ -163,5 +182,25 @@ export const nextVersion = (version: Semver, bump: ReleaseBump) => { : bump === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}` - return decodeSemver(raw) + return Schema.decodeUnknownEffect(Semver)(raw) } + +export const parseChangeset = (body: string, path: string) => + Effect.gen(function* () { + const parts = body.split(/^---$/m) + if (parts.length < 3) return yield* new ChangesetParseFailed({ path }) + const raw = yield* Effect.try({ + try: () => parseYaml(parts[1] ?? ''), + catch: (cause) => new YamlParseFailed({ path, detail: String(cause) }), + }) + const frontmatter = yield* Schema.decodeUnknownEffect(ChangesetFrontmatter)(raw).pipe( + Effect.mapError(() => new ChangesetParseFailed({ path })), + ) + const bumps = Object.values(frontmatter) + if (bumps.length === 0) return yield* new ChangesetParseFailed({ path }) + const releaseBumps = bumps.filter((b): b is ReleaseBump => b !== 'none') + const summary = (parts[2] ?? '').trim().split('\n').join(' ') + if (releaseBumps.length === 0) return { path, bump: 'none' as const, summary } + const bump = releaseBumps.reduce((acc, b) => RANK[b] >= RANK[acc] ? b : acc) + return { path, bump, summary } + }) diff --git a/scripts/tools/check-versions.ts b/scripts/tools/check-versions.ts index 8c688d9..d7de117 100755 --- a/scripts/tools/check-versions.ts +++ b/scripts/tools/check-versions.ts @@ -2,83 +2,15 @@ import { runMain } from '@effect/platform-deno/DenoRuntime' import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' -import { Console, Effect, FileSystem } from 'effect' - -import { - CRATES_DIR, - extractJsonVersion, - extractNixVersion, - extractTomlVersion, - FLAKE_NIX, - MANIFEST, - PLUGIN_MANIFEST, - ROOT_MANIFEST, - VersionMismatch, - WORKSPACE_CARGO, -} from '../lib/version-sync.ts' +import { Console, Effect } from 'effect' +import { checkAllSurfaces } from '../lib/version-files.ts' const program = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const npmText = yield* fs.readFileString(MANIFEST) - const npmVersion = yield* extractJsonVersion(npmText, MANIFEST) - - const workspaceText = yield* fs.readFileString(WORKSPACE_CARGO) - const workspaceVersion = yield* extractTomlVersion( - workspaceText, - '[workspace.package]', - WORKSPACE_CARGO, - ) - - const mismatches: Array = [] - if (workspaceVersion !== npmVersion) { - mismatches.push(`Cargo.toml workspace ${workspaceVersion} != npm ${npmVersion}`) - } - - const crateNames = yield* fs.readDirectory(CRATES_DIR) - for (const name of crateNames) { - const path = `${CRATES_DIR}/${name}/Cargo.toml` - if (!(yield* fs.exists(path))) continue - const text = yield* fs.readFileString(path) - const crateVersion = yield* extractTomlVersion(text, '[package]', path) - if (crateVersion !== npmVersion) { - mismatches.push(`${path} ${crateVersion} != npm ${npmVersion}`) - } - } - - if (yield* fs.exists(FLAKE_NIX)) { - const nixText = yield* fs.readFileString(FLAKE_NIX) - const nixVersion = yield* extractNixVersion(nixText, FLAKE_NIX) - if (nixVersion !== npmVersion) { - mismatches.push(`flake.nix ${nixVersion} != npm ${npmVersion}`) - } - } - - if (yield* fs.exists(ROOT_MANIFEST)) { - const rootText = yield* fs.readFileString(ROOT_MANIFEST) - const rootVersion = yield* extractJsonVersion(rootText, ROOT_MANIFEST) - if (rootVersion !== npmVersion) { - mismatches.push(`package.json root ${rootVersion} != npm ${npmVersion}`) - } - } - - let pluginChecked = false - if (yield* fs.exists(PLUGIN_MANIFEST)) { - const pluginText = yield* fs.readFileString(PLUGIN_MANIFEST) - const pluginVersion = yield* extractJsonVersion(pluginText, PLUGIN_MANIFEST) - if (pluginVersion !== npmVersion) { - mismatches.push(`${PLUGIN_MANIFEST} ${pluginVersion} != npm ${npmVersion}`) - } - pluginChecked = true - } - - if (mismatches.length > 0) { - return yield* new VersionMismatch({ mismatches }) - } - + const { expected, workspaceVersion, pluginChecked } = yield* checkAllSurfaces() yield* Console.log( pluginChecked - ? `check-versions: ok npm=${npmVersion} workspace=${workspaceVersion}` - : `check-versions: ok npm=${npmVersion} workspace=${workspaceVersion} (plugin manifest: none tracked)`, + ? `check-versions: ok npm=${expected} workspace=${workspaceVersion}` + : `check-versions: ok npm=${expected} workspace=${workspaceVersion} (plugin manifest: none tracked)`, ) }) diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index f58ba50..7418685 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -2,39 +2,18 @@ import { runMain } from '@effect/platform-deno/DenoRuntime' import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' -import { Console, Effect, FileSystem, Schema } from 'effect' - +import { Console, Effect, FileSystem } from 'effect' +import { bumpAllSurfaces } from '../lib/version-files.ts' import { - Bump, CHANGELOG, CHANGESET_DIR, extractJsonVersion, MANIFEST, nextVersion, + parseChangeset, + RANK, type ReleaseBump, } from '../lib/version-sync.ts' -import { bumpAllSurfaces } from '../lib/version-files.ts' - -const RANK: Record = { patch: 1, minor: 2, major: 3 } - -const isReleaseBump = (bump: Bump): bump is ReleaseBump => bump !== 'none' - -class Intent extends Schema.Class('Intent')({ - path: Schema.String, - bump: Bump, - summary: Schema.String, -}) {} - -const parseIntent = (path: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const body = yield* fs.readFileString(path) - const parts = body.split(/^---$/m) - const rawBump = /:\s*(major|minor|patch|none)/.exec(parts[1] ?? '')?.[1] ?? '' - const bump = yield* Schema.decodeUnknownEffect(Bump)(rawBump) - const summary = (parts[2] ?? '').trim().split('\n').join(' ') - return new Intent({ path, bump, summary }) - }) const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -45,20 +24,22 @@ const program = Effect.gen(function* () { return } - const intents = yield* Effect.all(pending.map((name) => parseIntent(`${CHANGESET_DIR}/${name}`))) - const releases = intents.filter((i) => isReleaseBump(i.bump)) + const intents = yield* Effect.all( + pending.map((name) => + Effect.gen(function* () { + const body = yield* fs.readFileString(`${CHANGESET_DIR}/${name}`) + return yield* parseChangeset(body, `${CHANGESET_DIR}/${name}`) + }) + ), + ) + const releases = intents.filter((i): i is typeof i & { bump: ReleaseBump } => i.bump !== 'none') if (releases.length === 0) { for (const i of intents) yield* fs.remove(i.path) yield* Console.log('only none intents; consumed without version bump') return } - const chosen = releases.sort((a, b) => { - const aBump = a.bump as ReleaseBump - const bBump = b.bump as ReleaseBump - return RANK[bBump] - RANK[aBump] - })[0] - const bump = chosen.bump as ReleaseBump + const bump = releases.reduce((acc, i) => RANK[i.bump] >= RANK[acc.bump] ? i : acc).bump const summary = releases.map((i) => ` - ${i.summary}`).join('\n') const manifestText = yield* fs.readFileString(MANIFEST) const version = yield* extractJsonVersion(manifestText, MANIFEST)