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..4041665 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.3.0" 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/deno.jsonc b/scripts/deno.jsonc index 7ca0562..4d56220 100644 --- a/scripts/deno.jsonc +++ b/scripts/deno.jsonc @@ -1,10 +1,13 @@ { "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/toml": "jsr:@std/toml@1.0.11", "@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..36b0092 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -3,10 +3,14 @@ "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: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": { @@ -15,6 +19,9 @@ "@std/cli@1.0.15": { "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" }, + "@std/collections@1.3.0": { + "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, @@ -24,6 +31,12 @@ "jsr:@std/internal" ] }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, "@std/yaml@1.0.4": { "integrity": "f1e38fa5e110ff8550e938715e52c33347676dd00a1db858e092dd21a9df9941" } @@ -38,6 +51,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 +211,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": { @@ -58,8 +285,11 @@ "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: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-files.ts b/scripts/lib/version-files.ts new file mode 100644 index 0000000..ed9f90a --- /dev/null +++ b/scripts/lib/version-files.ts @@ -0,0 +1,110 @@ +import { Effect, FileSystem } from 'effect' +import { + CRATES_DIR, + extractJsonVersion, + extractNixVersion, + extractTomlVersion, + FLAKE_NIX, + MANIFEST, + PLUGIN_MANIFEST, + replaceJsonVersion, + replaceNixVersion, + replaceTomlVersion, + ROOT_MANIFEST, + type Semver, + VersionMismatch, + WORKSPACE_CARGO, +} from './version-sync.ts' + +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, + header: '[workspace.package]' | '[package]', + 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) + }) + +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 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* rewriteToml(path, '[package]', next) + } + yield* rewriteNix(FLAKE_NIX, next) + yield* rewriteJson(ROOT_MANIFEST, next) + if (!(yield* fs.exists(PLUGIN_MANIFEST))) return false + yield* rewriteJson(PLUGIN_MANIFEST, next) + return true + }) + +export const checkAllSurfaces = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + 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 new file mode 100644 index 0000000..b57dbce --- /dev/null +++ b/scripts/lib/version-sync.ts @@ -0,0 +1,206 @@ +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+$/)) +export type Semver = typeof Semver.Type + +export const Bump = Schema.Literals(['major', 'minor', 'patch', 'none']) +export type Bump = typeof Bump.Type + +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 + +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', { + expected: Semver, + diffs: Schema.Array(Schema.Struct({ + path: Schema.String, + found: Semver, + })), +}) { + override get message(): string { + return this.diffs + .map((d) => `check-versions: ${d.path} ${d.found} != npm ${this.expected}`) + .join('\n') + } +} + +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}` + } +} + +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}` + } +} + +export class ChangesetParseFailed + extends Schema.TaggedError()('ChangesetParseFailed', { + path: Schema.String, + }) { + override get message(): string { + return `changeset is not YAML frontmatter: ${this.path}` + } +} + +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' +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' + +export const RANK: { readonly [K in ReleaseBump]: number } = { patch: 1, minor: 2, major: 3 } + +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 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* () { + 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) + 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) + 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) + } + 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* () { + 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 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) => { + 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 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 b00aa58..d7de117 100755 --- a/scripts/tools/check-versions.ts +++ b/scripts/tools/check-versions.ts @@ -1,53 +1,17 @@ -#!/usr/bin/env -S deno run --allow-read +#!/usr/bin/env -S deno run --allow-read --allow-env -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 m[1]; - } - } - return null; -} +import { runMain } from '@effect/platform-deno/DenoRuntime' +import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' +import { Console, Effect } from 'effect' +import { checkAllSurfaces } from '../lib/version-files.ts' -const npmManifest = JSON.parse(await Deno.readTextFile("npm/packages/comment-checker/package.json")); -const npmVersion = npmManifest.version as string; +const program = Effect.gen(function* () { + const { expected, workspaceVersion, pluginChecked } = yield* checkAllSurfaces() + yield* Console.log( + pluginChecked + ? `check-versions: ok npm=${expected} workspace=${workspaceVersion}` + : `check-versions: ok npm=${expected} workspace=${workspaceVersion} (plugin manifest: none tracked)`, + ) +}) -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}`); - } - } catch (e) { - if (e instanceof Deno.errors.NotFound) 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}`); +runMain(program.pipe(Effect.provide(DenoPlatform))) diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts index 12198f4..7418685 100755 --- a/scripts/tools/release-version.ts +++ b/scripts/tools/release-version.ts @@ -1,108 +1,67 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-env -const MANIFEST = 'npm/packages/comment-checker/package.json' -const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md' -const WORKSPACE_CARGO = 'Cargo.toml' -const RANK: Record = { patch: 1, minor: 2, major: 3 } +import { runMain } from '@effect/platform-deno/DenoRuntime' +import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices' +import { Console, Effect, FileSystem } from 'effect' +import { bumpAllSurfaces } from '../lib/version-files.ts' +import { + CHANGELOG, + CHANGESET_DIR, + extractJsonVersion, + MANIFEST, + nextVersion, + parseChangeset, + RANK, + type ReleaseBump, +} from '../lib/version-sync.ts' -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}`) +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 } - await Deno.writeTextFile(path, lines.join('\n')) -} - -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) => + 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 } -} -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 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 next = nextVersion(version, bump) + 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) + const next = yield* nextVersion(version, bump) -manifest.version = next -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` - try { - await bumpCargoToml(crateManifest, next) - } catch (e) { - if (e instanceof Deno.errors.NotFound) continue - throw e + const pluginBumped = yield* bumpAllSurfaces(next) + if (!pluginBumped) { + yield* 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)))