From 78357a9c63db67247c2f09495fd07f9398f55a6e Mon Sep 17 00:00:00 2001 From: chenkun Date: Mon, 5 Oct 2026 04:47:31 +0800 Subject: [PATCH 1/2] feat(changelog): dedupe generated entries against curated lines and repeated subjects Draft entries are normalized (bullet, trailing commit-hash annotation, punctuation, case) into a dedupe key; a commit is skipped when its text already appears as a curated [Unreleased] bullet or repeats an earlier commit's subject (oldest kept, cross-bucket). --- AGENTS.md | 2 +- scripts/changelog.ts | 28 ++++++++++++++++++++++++++-- test/changelog.test.ts | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c02724a..f01d546 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Behavioral facts verified against host source; getting these wrong is the histor Releases are **CI-gated** by `.github/workflows/release.yml`: pushing a `v` tag replays the full CI matrix (unit + real-host e2e, ubuntu+windows+macOS — reused from `ci.yml` via `workflow_call`) and only then publishes to npm with provenance and creates the GitHub Release. There is deliberately **no** `NPM_TOKEN` anywhere — npm's trusted-publisher binding only accepts this workflow's OIDC identity, so local `npm publish` fails by design. - Release flow (**bump-at-release-time**): pick the semver level from the changes accumulated on `main` since the last tag, run `npm version --no-git-tag-version` (bumps `package.json` and syncs `package-lock.json` in one step), commit as `chore: release vX.Y.Z` on a release branch, open a PR and merge it (direct pushes to `main` are rejected — the ruleset has no bypass actors). Then `git checkout main && git pull --ff-only` and `v=$(node -p "require('./package.json').version") && git tag -m "v$v" "v$v" && git push origin "v$v"`. **The tag must point at the merged `main` HEAD** — with a squash merge the release branch's local commit is not in `main`'s history, and `v*` tags are immutable, so a tag on the wrong commit cannot be moved without ruleset surgery. The `-m` is not optional on machines with `tag.gpgSign=true` (a bare `git tag` opens an editor and fails non-interactively). The guard job fails the run when tag ≠ package.json version or the version is already on npm. -- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (`chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`). +- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (deduplicated against curated lines and repeated same-subject commits; `chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`). - The `npm publish` step must stay directly in `release.yml`: npm validates the *calling* workflow's filename against the trusted-publisher binding — a publish hidden behind `workflow_call` would mismatch (reusing `ci.yml` for the test matrix only is fine). - One-time bootstrap (**done** 2026-10-03 — the binding is live and the v0.4.4 rehearsal published through it; keep for forks/re-creation): trusted publishing can only be configured once the package exists on npm (npm/cli#8544), so the **first** publish is a one-time manual `npm publish` (that one version carries no provenance). Here the package-creating publish was a minimal `0.0.0-stage` placeholder (2 files, `"stub": true`), followed ~1 min later by the real manual `0.4.3`; the stub was **unpublished 2026-10-04** (see next bullet) and no longer exists. Then npmjs.com → package → Settings → Trusted publisher → GitHub Actions: org/user `bytesnail`, repo `opencode-secrets-env`, workflow filename `release.yml` (case-sensitive, `.yml` included; npm does not validate the fields until a publish runs), allowed action `npm publish`. - Removing a stray npm version (playbook verified 2026-10-04): `npm unpublish @` only works within **72 h** of that version's publish; later it's npm-support territory. Pre-checks: `npm view dist-tags` (never unpublish the `latest` target — re-tag first) and per-version downloads at `https://api.npmjs.org/versions//last-week` (zero = safe). Gotchas hit in practice: the maintainer machine's registry is npmmirror (read-only mirror) — **every write needs `--registry https://registry.npmjs.org`**; `npm login --registry https://registry.npmjs.org --auth-type=web` prints a URL and polls, no TTY needed; with 2FA on the account, writes fail EOTP and npm **redacts the `https://www.npmjs.com/auth/cli/` URL as `***` in non-TTY output and logs** (authId is treated as a secret), so the web-OTP flow looks unreachable headless — workaround: run under a pseudo-TTY, `script -qec "npm unpublish @ --registry https://registry.npmjs.org --browser=false" /tmp/log` in the background; npm's `otplease` then prints the real URL unredacted, polls the `doneUrl`, and auto-retries the unpublish once the maintainer completes browser auth (works with any account factor — passkey/security key/TOTP — no TOTP secret needed locally). Verify: `npm view versions` no longer lists it and the tarball URL 404s; the npmjs.com versions tab and npmmirror lag a few minutes. diff --git a/scripts/changelog.ts b/scripts/changelog.ts index cf70bd7..ed771f6 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -3,8 +3,10 @@ // bumps package.json and then runs this: CHANGELOG.md's [Unreleased] section // becomes a dated section for the new version — entries already curated // under [Unreleased] are carried over verbatim, then the conventional -// commits since the previous tag are appended as draft entries — and the -// link definitions at the bottom are refreshed. The file is left staged +// commits since the previous tag are appended as draft entries +// (deduplicated: a commit whose text already appears as a curated line, or +// repeats an earlier commit's subject, is dropped) — and the link +// definitions at the bottom are refreshed. The file is left staged // (see the `version` script in package.json); review/trim the generated // entries before committing `chore: release vX.Y.Z`. // @@ -54,6 +56,18 @@ export function classify(subject: string): { bucket: string; text: string } | nu return null } +// Normalizes a changelog bullet for deduplication: drops the leading "- " +// and any trailing commit-hash annotation, trailing punctuation, and case — +// so a curated "- Plug leak." matches the generated "- Plug leak (`aaa1111`)", +// and two commits sharing a subject collapse into one entry. +export function dedupeKey(entry: string): string { + return entry + .replace(/^- /, "") + .replace(/\s*\((`[0-9a-f]+`(, )?)+\)\.?$/i, "") + .replace(/[\s.]+$/, "") + .toLowerCase() +} + // Merges generated bucket items into the curated section: buckets whose // `### ` heading already exists get their items appended in place; // the rest become new subsections (in BUCKETS order) after the curated text. @@ -100,10 +114,20 @@ export function renderChangelog(text: string, opts: RenderOptions): string | nul if (nextAt < 0) nextAt = text.length const curated = text.slice(headingEnd, nextAt).trim() + // Dedupe: seed the seen-set with the curated bullets, then keep only the + // first generated entry per normalized text (commits arrive oldest-first). + const seen = new Set() + for (const line of curated.split("\n")) { + const bullet = line.trimStart() + if (bullet.startsWith("- ")) seen.add(dedupeKey(bullet)) + } const byBucket = new Map() for (const { hash, subject } of commits) { const c = classify(subject) if (!c) continue + const key = dedupeKey(c.text) + if (seen.has(key)) continue + seen.add(key) let items = byBucket.get(c.bucket) if (!items) { items = [] diff --git a/test/changelog.test.ts b/test/changelog.test.ts index e3be0ff..e0dddb2 100644 --- a/test/changelog.test.ts +++ b/test/changelog.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { classify, renderChangelog } from "../scripts/changelog.ts" +import { classify, dedupeKey, renderChangelog } from "../scripts/changelog.ts" const REPO = "https://github.com/example/repo" @@ -72,6 +72,40 @@ test("renderChangelog carries curated entries, appends buckets, refreshes links" assert.match(out, /\[0\.4\.5\]: https:\/\/github\.com\/example\/repo\/compare\/v0\.4\.4\.\.\.v0\.4\.5/) }) +test("dedupeKey normalizes bullets, hashes, punctuation and case", () => { + assert.equal(dedupeKey("- Plug leak (`aaa1111`)"), "plug leak") + assert.equal(dedupeKey("- Plug leak (`aaa1111`, `bbb2222`)."), "plug leak") + assert.equal(dedupeKey("- Plug Leak."), "plug leak") + assert.equal(dedupeKey("Plug leak"), "plug leak") + assert.notEqual(dedupeKey("- Add flag"), dedupeKey("- Add flag validation")) +}) + +test("renderChangelog drops generated entries duplicating curated lines or earlier commits", () => { + const out = renderChangelog(FIXTURE, { + version: "0.5.1", + date: "2026-10-05", + prevTag: "v0.5.0", + repoUrl: REPO, + commits: [ + // Same text as the curated Fixed line (curated has no hash, ends with "."). + { hash: "aaa1111", subject: "fix: a hand-curated entry already written during development" }, + // Same subject twice: only the first (oldest) survives. + { hash: "bbb2222", subject: "fix: plug leak" }, + { hash: "ccc3333", subject: "fix: plug leak" }, + // Same text under a different bucket is still a duplicate. + { hash: "ddd4444", subject: "feat: plug leak" }, + // Merely sharing a prefix is not a duplicate. + { hash: "eee5555", subject: "fix: plug leak detection" }, + ], + }) + assert.ok(out) + assert.equal(out.match(/hand-curated entry/g)?.length, 1, "curated entry not duplicated") + assert.equal(out.match(/- Plug leak \(`bbb2222`\)/g)?.length, 1, "first occurrence kept") + assert.ok(!out.includes("ccc3333"), "second same-subject commit dropped") + assert.ok(!out.includes("ddd4444"), "cross-bucket duplicate dropped") + assert.ok(out.includes("- Plug leak detection (`eee5555`)"), "prefix-only lookalike kept") +}) + test("renderChangelog is a no-op when the version section exists", () => { assert.equal( renderChangelog(FIXTURE, { From 002790fa5151d530162f042484cd0b04266caebd Mon Sep 17 00:00:00 2001 From: chenkun Date: Mon, 5 Oct 2026 05:00:48 +0800 Subject: [PATCH 2/2] fix(changelog): real dedup via hash claims, PR-ref-aware text keys Verified against the actual CHANGELOG.md: curated prose is worded nothing like the commit subjects it covers, so normalized-text matching alone still appended semantic duplicates (e.g. the mtime-poll fix next to its own curated write-up). Curated entries now claim covered commits with backtick hashes (`aaa1111`, abbreviated or full); the generator skips claimed commits. dedupeKey also strips GitHub's '(#N)' squash suffix. The current [Unreleased] entries are annotated per the new convention. --- AGENTS.md | 2 +- CHANGELOG.md | 4 +-- scripts/changelog.ts | 34 +++++++++++++++++-------- test/changelog.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f01d546..387ea75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Behavioral facts verified against host source; getting these wrong is the histor Releases are **CI-gated** by `.github/workflows/release.yml`: pushing a `v` tag replays the full CI matrix (unit + real-host e2e, ubuntu+windows+macOS — reused from `ci.yml` via `workflow_call`) and only then publishes to npm with provenance and creates the GitHub Release. There is deliberately **no** `NPM_TOKEN` anywhere — npm's trusted-publisher binding only accepts this workflow's OIDC identity, so local `npm publish` fails by design. - Release flow (**bump-at-release-time**): pick the semver level from the changes accumulated on `main` since the last tag, run `npm version --no-git-tag-version` (bumps `package.json` and syncs `package-lock.json` in one step), commit as `chore: release vX.Y.Z` on a release branch, open a PR and merge it (direct pushes to `main` are rejected — the ruleset has no bypass actors). Then `git checkout main && git pull --ff-only` and `v=$(node -p "require('./package.json').version") && git tag -m "v$v" "v$v" && git push origin "v$v"`. **The tag must point at the merged `main` HEAD** — with a squash merge the release branch's local commit is not in `main`'s history, and `v*` tags are immutable, so a tag on the wrong commit cannot be moved without ruleset surgery. The `-m` is not optional on machines with `tag.gpgSign=true` (a bare `git tag` opens an editor and fails non-interactively). The guard job fails the run when tag ≠ package.json version or the version is already on npm. -- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (deduplicated against curated lines and repeated same-subject commits; `chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`). +- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (deduplicated: a commit is skipped when a curated entry claims its hash in backticks — curators annotate covered commits like `… as described. (\`aaa1111\`)` and may trim the claims at release review — or when its text, normalized for case, trailing punctuation, `(#N)` PR refs and hash annotations, matches a curated line or an earlier commit; `chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`). - The `npm publish` step must stay directly in `release.yml`: npm validates the *calling* workflow's filename against the trusted-publisher binding — a publish hidden behind `workflow_call` would mismatch (reusing `ci.yml` for the test matrix only is fine). - One-time bootstrap (**done** 2026-10-03 — the binding is live and the v0.4.4 rehearsal published through it; keep for forks/re-creation): trusted publishing can only be configured once the package exists on npm (npm/cli#8544), so the **first** publish is a one-time manual `npm publish` (that one version carries no provenance). Here the package-creating publish was a minimal `0.0.0-stage` placeholder (2 files, `"stub": true`), followed ~1 min later by the real manual `0.4.3`; the stub was **unpublished 2026-10-04** (see next bullet) and no longer exists. Then npmjs.com → package → Settings → Trusted publisher → GitHub Actions: org/user `bytesnail`, repo `opencode-secrets-env`, workflow filename `release.yml` (case-sensitive, `.yml` included; npm does not validate the fields until a publish runs), allowed action `npm publish`. - Removing a stray npm version (playbook verified 2026-10-04): `npm unpublish @` only works within **72 h** of that version's publish; later it's npm-support territory. Pre-checks: `npm view dist-tags` (never unpublish the `latest` target — re-tag first) and per-version downloads at `https://api.npmjs.org/versions//last-week` (zero = safe). Gotchas hit in practice: the maintainer machine's registry is npmmirror (read-only mirror) — **every write needs `--registry https://registry.npmjs.org`**; `npm login --registry https://registry.npmjs.org --auth-type=web` prints a URL and polls, no TTY needed; with 2FA on the account, writes fail EOTP and npm **redacts the `https://www.npmjs.com/auth/cli/` URL as `***` in non-TTY output and logs** (authId is treated as a secret), so the web-OTP flow looks unreachable headless — workaround: run under a pseudo-TTY, `script -qec "npm unpublish @ --registry https://registry.npmjs.org --browser=false" /tmp/log` in the background; npm's `otplease` then prints the real URL unredacted, polls the `doneUrl`, and auto-retries the unpublish once the maintainer completes browser auth (works with any account factor — passkey/security key/TOTP — no TOTP secret needed locally). Verify: `npm view versions` no longer lists it and the tarball URL 404s; the npmjs.com versions tab and npmmirror lag a few minutes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 551c146..e61d4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,10 @@ fixes, minor for features. where no event arrived within 60 s. The event watcher is now backed by a low-frequency mtime poll (default 5 s; new `pollIntervalMs` option), so a dropped event degrades to a few seconds' delay instead of a missed reload - until restart. + until restart. (`78134e8`) - Unit tests are now hermetic on machines that export ambient `XDG_*`/`OPENCODE_*` variables: the test entry point strips them, and the - inline-content rescan test pins its scan input. + inline-content rescan test pins its scan input. (`8bdfe06`, `d0cb49f`) ## [0.5.0] - 2026-10-04 diff --git a/scripts/changelog.ts b/scripts/changelog.ts index ed771f6..a6d9003 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -3,10 +3,12 @@ // bumps package.json and then runs this: CHANGELOG.md's [Unreleased] section // becomes a dated section for the new version — entries already curated // under [Unreleased] are carried over verbatim, then the conventional -// commits since the previous tag are appended as draft entries -// (deduplicated: a commit whose text already appears as a curated line, or -// repeats an earlier commit's subject, is dropped) — and the link -// definitions at the bottom are refreshed. The file is left staged +// commits since the previous tag are appended as draft entries — +// deduplicated: a commit is skipped when a curated entry claims its hash +// in backticks (curators should annotate covered commits this way, e.g. +// "… as described. (`aaa1111`)"), or when its normalized text matches a +// curated line or an earlier commit — and the link definitions at the +// bottom are refreshed. The file is left staged // (see the `version` script in package.json); review/trim the generated // entries before committing `chore: release vX.Y.Z`. // @@ -56,18 +58,26 @@ export function classify(subject: string): { bucket: string; text: string } | nu return null } -// Normalizes a changelog bullet for deduplication: drops the leading "- " -// and any trailing commit-hash annotation, trailing punctuation, and case — -// so a curated "- Plug leak." matches the generated "- Plug leak (`aaa1111`)", -// and two commits sharing a subject collapse into one entry. +// Normalizes a changelog bullet for text deduplication: drops the leading +// "- ", any trailing commit-hash annotation or "(#N)" PR ref, trailing +// punctuation, and case — so a curated "- Plug leak." matches the generated +// "- Plug leak (`aaa1111`)". Curated prose is usually worded nothing like +// the commit subject, though, so text matching alone cannot dedupe the +// real cases: curated entries claim the commits they cover by hash. export function dedupeKey(entry: string): string { return entry .replace(/^- /, "") .replace(/\s*\((`[0-9a-f]+`(, )?)+\)\.?$/i, "") + .replace(/\s*\(#\d+\)$/, "") .replace(/[\s.]+$/, "") .toLowerCase() } +// A backtick-quoted hex string (7-40 chars) anywhere in the curated section +// claims that commit: its generated draft entry is skipped. Abbreviated and +// full hashes match each other (prefix comparison either way). +const HASH_CLAIM = /`([0-9a-f]{7,40})`/gi + // Merges generated bucket items into the curated section: buckets whose // `### ` heading already exists get their items appended in place; // the rest become new subsections (in BUCKETS order) after the curated text. @@ -114,8 +124,11 @@ export function renderChangelog(text: string, opts: RenderOptions): string | nul if (nextAt < 0) nextAt = text.length const curated = text.slice(headingEnd, nextAt).trim() - // Dedupe: seed the seen-set with the curated bullets, then keep only the - // first generated entry per normalized text (commits arrive oldest-first). + // Dedupe: collect hash claims and seed the seen-set with the curated + // bullets, then drop claimed commits and keep only the first generated + // entry per normalized text (commits arrive oldest-first). + const claims: string[] = [] + for (const m of curated.matchAll(HASH_CLAIM)) claims.push(m[1]!.toLowerCase()) const seen = new Set() for (const line of curated.split("\n")) { const bullet = line.trimStart() @@ -125,6 +138,7 @@ export function renderChangelog(text: string, opts: RenderOptions): string | nul for (const { hash, subject } of commits) { const c = classify(subject) if (!c) continue + if (claims.some((claim) => claim.startsWith(hash) || hash.startsWith(claim))) continue const key = dedupeKey(c.text) if (seen.has(key)) continue seen.add(key) diff --git a/test/changelog.test.ts b/test/changelog.test.ts index e0dddb2..8c80417 100644 --- a/test/changelog.test.ts +++ b/test/changelog.test.ts @@ -72,14 +72,69 @@ test("renderChangelog carries curated entries, appends buckets, refreshes links" assert.match(out, /\[0\.4\.5\]: https:\/\/github\.com\/example\/repo\/compare\/v0\.4\.4\.\.\.v0\.4\.5/) }) -test("dedupeKey normalizes bullets, hashes, punctuation and case", () => { +test("dedupeKey normalizes bullets, hashes, PR refs, punctuation and case", () => { assert.equal(dedupeKey("- Plug leak (`aaa1111`)"), "plug leak") assert.equal(dedupeKey("- Plug leak (`aaa1111`, `bbb2222`)."), "plug leak") assert.equal(dedupeKey("- Plug Leak."), "plug leak") assert.equal(dedupeKey("Plug leak"), "plug leak") + assert.equal(dedupeKey("Back the watcher (#6)"), "back the watcher") + assert.equal(dedupeKey("- Back the watcher (#6) (`aaa1111`)"), "back the watcher") assert.notEqual(dedupeKey("- Add flag"), dedupeKey("- Add flag validation")) }) +test("renderChangelog skips commits a curated entry claims by hash, however worded", () => { + const text = `# Changelog + +## [Unreleased] + +### Fixed + +- Prose write-up worded nothing like the commit subject, with the claim on + a continuation line. (\`aaa1111\`) +- Another entry claiming several commits at once (\`bbb2222\`, \`ccc333344445555\`). + +## [0.5.0] - 2026-10-04 + +- Old. + +[Unreleased]: ${REPO}/compare/v0.5.0...HEAD +[0.5.0]: ${REPO}/compare/v0.4.5...v0.5.0 +` + const out = renderChangelog(text, { + version: "0.5.1", + date: "2026-10-05", + prevTag: "v0.5.0", + repoUrl: REPO, + commits: [ + { hash: "aaa1111", subject: "fix: totally different wording" }, + { hash: "bbb2222", subject: "feat: multi-claim first" }, + { hash: "ccc3333", subject: "feat: multi-claim second" }, + { hash: "ddd4444", subject: "fix: unclaimed sibling" }, + ], + }) + assert.ok(out) + assert.ok(!out.includes("Totally different wording"), "claimed commit dropped") + assert.ok(!out.includes("Multi-claim first"), "first of multi-claim dropped") + assert.ok(!out.includes("Multi-claim second"), "full-hash claim matches abbreviated %h") + assert.ok(out.includes("- Unclaimed sibling (`ddd4444`)"), "unclaimed commit kept") + // The claims ride along verbatim as part of the curated entries. + assert.ok(out.includes("(`aaa1111`)"), "curated entry carried verbatim with its claim") +}) + +test("renderChangelog text-dedupes despite GitHub's '(#N)' squash suffix", () => { + const text = `# Changelog\n\n## [Unreleased]\n\n### Internal\n\n- Add single 'gate' check context.\n` + const out = renderChangelog(text, { + version: "0.5.1", + date: "2026-10-05", + prevTag: "v0.5.0", + repoUrl: REPO, + commits: [{ hash: "eee5555", subject: "ci: add single 'gate' check context (#3)" }], + }) + assert.ok(out) + assert.equal(out.match(/gate' check context/g)?.length, 1, "PR-ref suffix does not defeat dedup") + assert.ok(!out.includes("eee5555")) +}) + test("renderChangelog drops generated entries duplicating curated lines or earlier commits", () => { const out = renderChangelog(FIXTURE, { version: "0.5.1",