From 12b95171dc6b66ff25087d382cb11ba73b24cd85 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Mar 2026 08:02:46 -0700 Subject: [PATCH 1/5] docs: frame git-warp upgrade audit cycle (#312) --- ROADMAP.md | 4 +- docs/README.md | 1 + docs/design/git-warp-upgrade-audit.md | 155 ++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 docs/design/git-warp-upgrade-audit.md diff --git a/ROADMAP.md b/ROADMAP.md index bc2a7bc..9d13434 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,6 +124,7 @@ Goal: Deliverables: +- git-warp audit / upgrade cycle before major Hill 1 implementation (issue [#312](https://github.com/flyingrobots/git-mind/issues/312)) - bootstrap command contract with default write behavior and `--dry-run` - canonical repo fixture substrate for repository-shaped bootstrap scenarios (issue [#311](https://github.com/flyingrobots/git-mind/issues/311)) - repo-local artifact inventory and scan boundaries @@ -146,9 +147,10 @@ Primary references: - [docs/design/git-mind.md](docs/design/git-mind.md) - [docs/design/h1-semantic-bootstrap.md](docs/design/h1-semantic-bootstrap.md) +- [docs/design/git-warp-upgrade-audit.md](docs/design/git-warp-upgrade-audit.md) - [docs/design/repo-fixture-strategy.md](docs/design/repo-fixture-strategy.md) - issue [#303](https://github.com/flyingrobots/git-mind/issues/303) -- issues [#304](https://github.com/flyingrobots/git-mind/issues/304), [#305](https://github.com/flyingrobots/git-mind/issues/305), [#306](https://github.com/flyingrobots/git-mind/issues/306), [#307](https://github.com/flyingrobots/git-mind/issues/307), [#310](https://github.com/flyingrobots/git-mind/issues/310), and [#311](https://github.com/flyingrobots/git-mind/issues/311) +- issues [#304](https://github.com/flyingrobots/git-mind/issues/304), [#305](https://github.com/flyingrobots/git-mind/issues/305), [#306](https://github.com/flyingrobots/git-mind/issues/306), [#307](https://github.com/flyingrobots/git-mind/issues/307), [#310](https://github.com/flyingrobots/git-mind/issues/310), [#311](https://github.com/flyingrobots/git-mind/issues/311), and [#312](https://github.com/flyingrobots/git-mind/issues/312) --- diff --git a/docs/README.md b/docs/README.md index bac1d45..5dea030 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ These describe what Git Mind is now and how work should be judged: - [ROADMAP.md](../ROADMAP.md) — active Hills, supporting lanes, and playback cadence - [Git Mind Product Frame](./design/git-mind.md) — IBM Design Thinking style product frame - [Hill 1 Semantic Bootstrap Spec](./design/h1-semantic-bootstrap.md) — first executable Hill 1 slice +- [git-warp Upgrade Audit](./design/git-warp-upgrade-audit.md) — next enabling cycle before major Hill 1 implementation - [Git Mind North Star](./VISION_NORTH_STAR.md) — longer-form strategic articulation - [ADR-0005](./adr/ADR-0005.md) — official planning and governance model - [ADR-0006](./adr/ADR-0006.md) — official delivery cycle and tests-as-spec model diff --git a/docs/design/git-warp-upgrade-audit.md b/docs/design/git-warp-upgrade-audit.md new file mode 100644 index 0000000..4073513 --- /dev/null +++ b/docs/design/git-warp-upgrade-audit.md @@ -0,0 +1,155 @@ +# git-warp Upgrade Audit + +Status: draft for execution + +Related: + +- [ROADMAP.md](../../ROADMAP.md) +- [ADR-0005](../adr/ADR-0005.md) +- [ADR-0006](../adr/ADR-0006.md) +- [Repo Fixture Strategy](./repo-fixture-strategy.md) +- issue [#312](https://github.com/flyingrobots/git-mind/issues/312) + +## Purpose + +Define the next enabling cycle before major Hill 1 implementation: + +> audit and upgrade Git Mind's `@git-stunts/git-warp` dependency so new Hill 1 work is not built on an outdated substrate by accident. + +This is not a generic dependency bump. +It is a boundary audit plus upgrade cycle. + +## Sponsor User + +- A Git Mind maintainer extending the product who needs confidence that core graph, provenance, and time-travel behavior still behaves as Git Mind expects on a current git-warp version. + +## Job To Be Done + +- When I build new Git Mind behavior on top of git-warp, help me do it against a revalidated substrate with explicit compatibility evidence instead of stale assumptions. + +## Context + +Current local dependency state: + +- declared in `package.json` as `^11.5.0` +- locked and installed at `11.5.0` + +Live npm registry state checked on 2026-03-25: + +- latest published `@git-stunts/git-warp` version: `14.16.2` + +That means Git Mind is currently three major versions behind the latest published package. + +This does not automatically mean "upgrade immediately no matter what." +It does mean Git Mind should not keep expanding Hill 1 behavior without auditing the real upgrade surface first. + +## Why This Cycle Exists + +The goal is explicitly **not** to build a lot of new behavior on top of git-warp right now. + +The goal is: + +- understand the dependency boundary Git Mind actually uses +- strengthen the tests around that boundary +- upgrade deliberately +- keep future Hill 1 work from inheriting avoidable substrate drift + +## Observed Dependency Boundary + +Based on current code inspection, Git Mind directly imports and depends on a relatively narrow but important git-warp surface. + +### Direct Imports + +From `@git-stunts/git-warp`, Git Mind currently imports: + +- default export `WarpGraph` +- `GitGraphAdapter` +- `CONTENT_PROPERTY_KEY` + +### Verified Graph Instance Methods Used In `src/` + +Git Mind currently relies on these graph instance methods: + +- `createPatch()` +- `hasNode()` +- `getNodeProps()` +- `getNodes()` +- `getEdges()` +- `getContentOid()` +- `getContent()` +- `materialize({ ceiling })` +- `observer(name, config)` +- `discoverTicks()` + +### Verified Patch Methods Used In `src/` + +Git Mind currently relies on these patch methods: + +- `addNode()` +- `addEdge()` +- `removeEdge()` +- `setProperty()` +- `setEdgeProperty()` +- `attachContent()` +- `commit()` + +### High-Risk Semantics + +The upgrade should pay special attention to: + +1. `WarpGraph.open(...)` and `GitGraphAdapter` initialization semantics +2. time-travel behavior of `materialize({ ceiling })` +3. observer/filter behavior via `graph.observer(...)` +4. content attachment and retrieval APIs +5. tick discovery / historical traversal assumptions +6. patch commit semantics and idempotency + +## Known Mismatches Already Exposed + +At least one contributor-facing document still says Git Mind depends on git-warp via a local path. + +That no longer matches the actual package metadata and should be corrected as part of this cycle. + +## Acceptance Criteria + +This cycle succeeds when: + +1. Git Mind's actual git-warp dependency surface is documented and reviewed. +2. Compatibility-sensitive behaviors are protected by tests. +3. The upgrade target version is chosen deliberately, not by vibes. +4. Git Mind installs and tests cleanly against the chosen upgraded version. +5. Docs reflect the real dependency model afterward. + +## Execution Model + +Per [ADR-0006](../adr/ADR-0006.md), this cycle should follow the normal design-to-test flow: + +1. finalize the upgrade-audit design artifact +2. translate the risky boundary into failing tests +3. use shared repo fixtures where repository-shaped behavior matters +4. upgrade and implement until tests are green +5. run a playback / retrospective +6. update README and contributor docs if reality changed + +## Recommended First Work Sequence + +1. Inventory git-warp touchpoints in `src/` and relevant tests. +2. Add or strengthen tests around: + - graph open/init + - node/edge mutation + - content attachment + - time-travel / epoch behavior + - observer behavior if still relevant +3. Review upstream changes between `11.5.0` and the candidate target. +4. Upgrade the dependency and lockfile on a dedicated branch. +5. Fix breakage explicitly rather than papering over it. + +## Playback Questions + +Use these questions for the cycle retrospective: + +1. Did we actually reduce substrate risk, or just move version numbers? +2. Are the important git-warp assumptions now executable as tests? +3. Did the upgrade simplify or complicate future Hill 1 work? +4. Did we discover any Git Mind behavior that was depending on accidental substrate quirks? +5. What follow-on work should be backlogged before deeper Hill 1 implementation resumes? From 8eba886174a0e1ac163db7996fdb600cc08e958e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Mar 2026 08:26:59 -0700 Subject: [PATCH 2/5] feat: upgrade git-warp to v14.16.2 (#312) --- package-lock.json | 136 ++++++++++++++++++++--------- package.json | 2 +- src/cli/commands.js | 7 +- src/content.js | 18 +++- src/epoch.js | 7 +- src/export.js | 5 +- src/merge.js | 3 +- src/nodes.js | 14 +-- src/prop-bag.js | 64 ++++++++++++++ src/review.js | 25 +++--- src/suggest.js | 9 +- src/views.js | 7 +- test/contracts.integration.test.js | 11 +-- test/diff.test.js | 10 ++- test/graph.test.js | 3 +- test/import.test.js | 5 +- test/merge.test.js | 5 +- test/prop-bag.test.js | 37 ++++++++ 18 files changed, 271 insertions(+), 97 deletions(-) create mode 100644 src/prop-bag.js create mode 100644 test/prop-bag.test.js diff --git a/package-lock.json b/package-lock.json index d18a6fa..72c4b8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "5.0.0", "license": "Apache-2.0", "dependencies": { - "@git-stunts/git-warp": "^11.5.0", + "@git-stunts/git-warp": "^14.16.2", "@git-stunts/plumbing": "^2.8.0", "ajv": "^8.17.1", "chalk": "^5.3.0", @@ -106,9 +106,9 @@ } }, "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", - "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", "cpu": [ "arm64" ], @@ -119,9 +119,9 @@ ] }, "node_modules/@cbor-extract/cbor-extract-darwin-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", - "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ ] }, "node_modules/@cbor-extract/cbor-extract-linux-arm": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", - "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", "cpu": [ "arm" ], @@ -145,9 +145,9 @@ ] }, "node_modules/@cbor-extract/cbor-extract-linux-arm64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", - "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", "cpu": [ "arm64" ], @@ -158,9 +158,9 @@ ] }, "node_modules/@cbor-extract/cbor-extract-linux-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", - "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", "cpu": [ "x64" ], @@ -171,9 +171,9 @@ ] }, "node_modules/@cbor-extract/cbor-extract-win32-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", - "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", "cpu": [ "x64" ], @@ -803,6 +803,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@flyingrobots/bijou": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou/-/bijou-0.2.0.tgz", + "integrity": "sha512-Oix2Kqq4w87KCkyK2W+8u4E4aGVQiraUy8BF3Bk/NRtT+UlUI0ETs+E7GwpwOyOvHvt0cIOjcMmVPxzKa52P4A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@flyingrobots/bijou-node": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-node/-/bijou-node-0.2.0.tgz", + "integrity": "sha512-QaIaoBF0OMRHGtLsga1knplfFEmAeC6Lt4SxWkCKIJahMdNqXatCWM3RdzXcbjfcXqRIXyeEpm1agmmwi4gneQ==", + "license": "MIT", + "dependencies": { + "@flyingrobots/bijou": "0.2.0", + "chalk": "^5.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@flyingrobots/bijou-tui": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-tui/-/bijou-tui-0.2.0.tgz", + "integrity": "sha512-pXEo/Am6svRIKvez7926avdGUbfVndlSOpidBPc42YjCQHU5ZQrEuJpjI7niJb63N0ruxu0VXHci8N0wzBYSow==", + "license": "MIT", + "dependencies": { + "@flyingrobots/bijou": "0.2.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@git-stunts/alfred": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@git-stunts/alfred/-/alfred-0.4.0.tgz", @@ -813,11 +847,14 @@ } }, "node_modules/@git-stunts/git-cas": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-3.0.0.tgz", - "integrity": "sha512-5uqIsTukE+8f1h317ZmGneYpTJ1ecBxg16QJxvF3kNrfQR3/DcAH4fQyMRkCIQtSHEz2p6UpOwpM10R9dEQm/w==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-5.3.2.tgz", + "integrity": "sha512-DLh5fBxTTJTHOUjH0VifSZSHSmffrrdt5cLnFUxMZ/+dcfbnqdQLW0/7prNnDAT05qRXPWGNW27dCZNI/5eN0g==", "license": "Apache-2.0", "dependencies": { + "@flyingrobots/bijou": "^0.2.0", + "@flyingrobots/bijou-node": "^0.2.0", + "@flyingrobots/bijou-tui": "^0.2.0", "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", @@ -841,13 +878,13 @@ } }, "node_modules/@git-stunts/git-warp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-11.5.0.tgz", - "integrity": "sha512-IzgAU8FEmd+W/OoawfPNciVS7GpqjSVagkUEv1Cc6HT24PdmeipNfufL1WgS+XeTz8L+kEdnjGzBG/dhMrhNEg==", + "version": "14.16.2", + "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-14.16.2.tgz", + "integrity": "sha512-PHVTay67Agg/E+4fBufZei8OAV5dZO9qbupNGiM/leJ2NCdzstyH03wac5SJ7iW0Uh/4s3weTPoWXx9vvCBegg==", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", - "@git-stunts/git-cas": "^3.0.0", + "@git-stunts/git-cas": "^5.3.2", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "boxen": "^7.1.1", @@ -857,9 +894,10 @@ "elkjs": "^0.11.0", "figures": "^6.0.1", "roaring": "^2.7.0", + "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", - "zod": "^3.24.1" + "zod": "3.24.1" }, "bin": { "git-warp": "bin/git-warp", @@ -869,6 +907,15 @@ "node": ">=22.0.0" } }, + "node_modules/@git-stunts/git-warp/node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@git-stunts/plumbing": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@git-stunts/plumbing/-/plumbing-2.8.0.tgz", @@ -1996,9 +2043,9 @@ } }, "node_modules/cbor-extract": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", - "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2009,21 +2056,21 @@ "download-cbor-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", - "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", - "@cbor-extract/cbor-extract-linux-arm": "2.2.0", - "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", - "@cbor-extract/cbor-extract-linux-x64": "2.2.0", - "@cbor-extract/cbor-extract-win32-x64": "2.2.0" + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" } }, "node_modules/cbor-x": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", - "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz", + "integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==", "license": "MIT", "optionalDependencies": { - "cbor-extract": "^2.2.0" + "cbor-extract": "^2.2.2" } }, "node_modules/chai": { @@ -3855,6 +3902,15 @@ } } }, + "node_modules/roaring-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/roaring-wasm/-/roaring-wasm-1.1.0.tgz", + "integrity": "sha512-mhNqA0BOqIW7k4ZYSYe3kCyvn5T3VWT+2661G7fZH0C6XcVkGoTDLAqne7b47xCNQE6LhuYviMKBnzbOiBXkdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", diff --git a/package.json b/package.json index 03136bf..a21e370 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "format": "prettier --write 'src/**/*.js' 'bin/**/*.js'" }, "dependencies": { - "@git-stunts/git-warp": "^11.5.0", + "@git-stunts/git-warp": "^14.16.2", "@git-stunts/plumbing": "^2.8.0", "ajv": "^8.17.1", "chalk": "^5.3.0", diff --git a/src/cli/commands.js b/src/cli/commands.js index ccaa1a1..5660263 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -26,6 +26,7 @@ import { computeDiff } from '../diff.js'; import { DEFAULT_CONTEXT } from '../context-envelope.js'; import { loadExtension, registerExtension, removeExtension, listExtensions, validateExtension } from '../extension.js'; import { writeContent, readContent, getContentMeta, deleteContent } from '../content.js'; +import { getProp } from '../prop-bag.js'; import { success, error, info, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff, formatExtensionList, formatContentMeta } from './format.js'; /** @@ -90,9 +91,9 @@ export async function resolveContext(cwd, envelope) { `Define it with: git mind set observer:${observer} match 'prefix:*'`, ); } - const config = { match: propsMap.get('match') }; - const expose = propsMap.get('expose'); - const redact = propsMap.get('redact'); + const config = { match: getProp(propsMap, 'match') }; + const expose = getProp(propsMap, 'expose'); + const redact = getProp(propsMap, 'redact'); if (expose !== undefined) config.expose = expose; if (redact !== undefined) config.redact = redact; graph = await graph.observer(observer, config); diff --git a/src/content.js b/src/content.js index 293faa1..8cf973a 100644 --- a/src/content.js +++ b/src/content.js @@ -13,10 +13,22 @@ */ import { CONTENT_PROPERTY_KEY } from '@git-stunts/git-warp'; +import { getProp } from './prop-bag.js'; const MIME_KEY = '_content.mime'; const SIZE_KEY = '_content.size'; +/** + * Normalize git-warp content payloads to a UTF-8 string. + * + * @param {string | Uint8Array | Buffer} content + * @returns {string} + */ +function decodeContent(content) { + if (typeof content === 'string') return content; + return Buffer.from(content).toString('utf-8'); +} + /** * @typedef {object} ContentMeta * @property {string} sha - Git blob OID @@ -97,7 +109,7 @@ export async function readContent(graph, nodeId) { ); } - return { content: contentBuf.toString('utf-8'), meta }; + return { content: decodeContent(contentBuf), meta }; } /** @@ -121,8 +133,8 @@ export async function getContentMeta(graph, nodeId) { return { sha, - mime: propsMap?.get(MIME_KEY) ?? 'text/plain', - size: propsMap?.get(SIZE_KEY) ?? 0, + mime: getProp(propsMap, MIME_KEY) ?? 'text/plain', + size: getProp(propsMap, SIZE_KEY) ?? 0, }; } diff --git a/src/epoch.js b/src/epoch.js index a7fc3be..cd2cc61 100644 --- a/src/epoch.js +++ b/src/epoch.js @@ -7,6 +7,7 @@ */ import { execFileSync } from 'node:child_process'; +import { getProp } from './prop-bag.js'; /** * @typedef {object} EpochInfo @@ -70,9 +71,9 @@ export async function lookupEpoch(graph, commitSha) { if (!propsMap) return null; return { - tick: propsMap.get('tick'), - fullSha: propsMap.get('fullSha'), - recordedAt: propsMap.get('recordedAt'), + tick: getProp(propsMap, 'tick'), + fullSha: getProp(propsMap, 'fullSha'), + recordedAt: getProp(propsMap, 'recordedAt'), }; } diff --git a/src/export.js b/src/export.js index ce55ec7..be80034 100644 --- a/src/export.js +++ b/src/export.js @@ -7,6 +7,7 @@ import { writeFile } from 'node:fs/promises'; import yaml from 'js-yaml'; import { extractPrefix } from './validators.js'; +import { getPropEntries, getPropSize } from './prop-bag.js'; /** Edge property keys excluded from export (timestamps are system-managed). */ const EXCLUDED_EDGE_PROPS = new Set(['createdAt', 'importedAt', 'reviewedAt']); @@ -58,9 +59,9 @@ export async function exportGraph(graph, opts = {}) { const propsMap = await graph.getNodeProps(id); const entry = { id }; - if (propsMap && propsMap.size > 0) { + if (getPropSize(propsMap) > 0) { const properties = {}; - for (const [key, value] of propsMap) { + for (const [key, value] of getPropEntries(propsMap)) { properties[key] = value; } entry.properties = properties; diff --git a/src/merge.js b/src/merge.js index 50737fc..bd47dac 100644 --- a/src/merge.js +++ b/src/merge.js @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'; import { initGraph } from './graph.js'; +import { getPropEntries } from './prop-bag.js'; import { qualifyNodeId } from './remote.js'; /** @@ -89,7 +90,7 @@ export async function mergeFromRepo(localGraph, remoteRepoPath, opts = {}) { // Copy node properties const propsMap = await remoteGraph.getNodeProps(nodeId); if (propsMap) { - for (const [key, value] of propsMap) { + for (const [key, value] of getPropEntries(propsMap)) { patch.setProperty(qualifiedId, key, value); } } diff --git a/src/nodes.js b/src/nodes.js index bc4180a..0612e41 100644 --- a/src/nodes.js +++ b/src/nodes.js @@ -4,6 +4,7 @@ */ import { extractPrefix, classifyPrefix } from './validators.js'; +import { getProp, toPropObject } from './prop-bag.js'; /** * @typedef {object} NodeInfo @@ -27,14 +28,7 @@ export async function getNode(graph, id) { const prefix = extractPrefix(id); const propsMap = await graph.getNodeProps(id); - - // Convert Map to plain object - const properties = {}; - if (propsMap) { - for (const [key, value] of propsMap) { - properties[key] = value; - } - } + const properties = toPropObject(propsMap); return { id, @@ -79,7 +73,7 @@ export async function setNodeProperty(graph, id, key, value) { // Read current value const propsMap = await graph.getNodeProps(id); - const previous = propsMap?.get(key) ?? null; + const previous = getProp(propsMap, key) ?? null; const changed = previous !== value; if (changed) { @@ -118,7 +112,7 @@ export async function unsetNodeProperty(graph, id, key) { } const propsMap = await graph.getNodeProps(id); - const previous = propsMap?.get(key) ?? null; + const previous = getProp(propsMap, key) ?? null; const removed = previous != null; if (removed) { diff --git a/src/prop-bag.js b/src/prop-bag.js new file mode 100644 index 0000000..8665679 --- /dev/null +++ b/src/prop-bag.js @@ -0,0 +1,64 @@ +/** + * @module prop-bag + * Compatibility helpers for git-warp node property bag shapes. + * + * Older git-warp releases returned Map instances from getNodeProps(). + * Newer releases return plain objects. Git Mind supports both here. + */ + +/** + * @typedef {Map | Record | null | undefined} PropBag + */ + +/** + * Read a property value from a property bag. + * + * @param {PropBag} props + * @param {string} key + * @returns {unknown} + */ +export function getProp(props, key) { + if (!props) return undefined; + if (typeof props.get === 'function') { + return props.get(key); + } + return Object.prototype.hasOwnProperty.call(props, key) ? props[key] : undefined; +} + +/** + * Return all entries from a property bag. + * + * @param {PropBag} props + * @returns {Array<[string, unknown]>} + */ +export function getPropEntries(props) { + if (!props) return []; + if (typeof props.entries === 'function') { + return [...props.entries()]; + } + return Object.entries(props); +} + +/** + * Count entries in a property bag. + * + * @param {PropBag} props + * @returns {number} + */ +export function getPropSize(props) { + if (!props) return 0; + if (typeof props.size === 'number') { + return props.size; + } + return Object.keys(props).length; +} + +/** + * Convert a property bag to a plain object. + * + * @param {PropBag} props + * @returns {Record} + */ +export function toPropObject(props) { + return Object.fromEntries(getPropEntries(props)); +} diff --git a/src/review.js b/src/review.js index f22d651..d3b9bf9 100644 --- a/src/review.js +++ b/src/review.js @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto'; import { isLowConfidence } from './validators.js'; import { removeEdge, createEdge } from './edges.js'; +import { getProp } from './prop-bag.js'; /** * @typedef {object} PendingSuggestion @@ -77,7 +78,7 @@ async function recordDecision(graph, decision) { * Fetch all decision-node IDs and their properties from the graph. * * @param {import('@git-stunts/git-warp').default} graph - * @returns {Promise | null }>>} + * @returns {Promise>} */ async function fetchDecisionProps(graph) { const nodes = await graph.getNodes(); @@ -102,9 +103,9 @@ export async function getPendingSuggestions(graph) { const reviewedKeys = new Set(); for (const { props: propsMap } of await fetchDecisionProps(graph)) { if (!propsMap) continue; - const source = propsMap.get('source'); - const target = propsMap.get('target'); - const edgeType = propsMap.get('edgeType'); + const source = getProp(propsMap, 'source'); + const target = getProp(propsMap, 'target'); + const edgeType = getProp(propsMap, 'edgeType'); if (source && target && edgeType) { reviewedKeys.add(`${source}|${target}|${edgeType}`); } @@ -269,19 +270,19 @@ export async function getReviewHistory(graph, filter = {}) { for (const { id, props: propsMap } of await fetchDecisionProps(graph)) { if (!propsMap) continue; - const action = propsMap.get('action'); + const action = getProp(propsMap, 'action'); if (filter.action && action !== filter.action) continue; decisions.push({ id, action, - source: propsMap.get('source'), - target: propsMap.get('target'), - edgeType: propsMap.get('edgeType'), - confidence: propsMap.get('confidence'), - rationale: propsMap.get('rationale'), - timestamp: propsMap.get('timestamp'), - reviewer: propsMap.get('reviewer'), + source: getProp(propsMap, 'source'), + target: getProp(propsMap, 'target'), + edgeType: getProp(propsMap, 'edgeType'), + confidence: getProp(propsMap, 'confidence'), + rationale: getProp(propsMap, 'rationale'), + timestamp: getProp(propsMap, 'timestamp'), + reviewer: getProp(propsMap, 'reviewer'), }); } diff --git a/src/suggest.js b/src/suggest.js index f026db6..a23f79d 100644 --- a/src/suggest.js +++ b/src/suggest.js @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'; import { validateNodeId, validateEdgeType, validateConfidence } from './validators.js'; // TODO: import from './edges.js' when context-aware suggestions are implemented import { extractContext } from './context.js'; +import { getProp } from './prop-bag.js'; /** * @typedef {object} Suggestion @@ -201,11 +202,11 @@ export async function filterRejected(suggestions, graph) { const propsResults = await Promise.all(decisionNodes.map(id => graph.getNodeProps(id))); for (const propsMap of propsResults) { if (!propsMap) continue; - const action = propsMap.get('action'); + const action = getProp(propsMap, 'action'); if (action !== 'reject') continue; - const source = propsMap.get('source'); - const target = propsMap.get('target'); - const edgeType = propsMap.get('edgeType'); + const source = getProp(propsMap, 'source'); + const target = getProp(propsMap, 'target'); + const edgeType = getProp(propsMap, 'edgeType'); if (source && target && edgeType) { rejectedKeys.add(`${source}|${target}|${edgeType}`); } diff --git a/src/views.js b/src/views.js index 4296941..beb3752 100644 --- a/src/views.js +++ b/src/views.js @@ -7,6 +7,7 @@ import { extractPrefix, isLowConfidence } from './validators.js'; import { composeLenses } from './lens.js'; import { buildAdjacency, topoSort, detectCycles, walkChain, findRoots } from './dag.js'; +import { getPropSize, toPropObject } from './prop-bag.js'; // ── Status classification ─────────────────────────────────────── @@ -142,10 +143,8 @@ export async function renderView(graph, viewName, options) { // TODO: batch API at scale — O(N) getNodeProps calls is fine at ~50 nodes for (const id of nodes) { const propsMap = await graph.getNodeProps(id); - if (propsMap && propsMap.size > 0) { - const props = {}; - for (const [k, v] of propsMap) props[k] = v; - nodeProps.set(id, props); + if (getPropSize(propsMap) > 0) { + nodeProps.set(id, toPropObject(propsMap)); } } } diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js index 9fb4ccf..d0e652e 100644 --- a/test/contracts.integration.test.js +++ b/test/contracts.integration.test.js @@ -15,6 +15,7 @@ import Ajv from 'ajv/dist/2020.js'; const BIN = join(import.meta.dirname, '..', 'bin', 'git-mind.js'); const SCHEMA_DIR = join(import.meta.dirname, '..', 'docs', 'contracts', 'cli'); const FIXTURE = join(import.meta.dirname, 'fixtures', 'echo-seed.yaml'); +const SLOW_CLI_TIMEOUT = 15_000; /** Load a schema by filename. */ async function loadSchema(name) { @@ -131,7 +132,7 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }); + }, SLOW_CLI_TIMEOUT); it('doctor --json validates against doctor.schema.json', async () => { const schema = await loadSchema('doctor.schema.json'); @@ -216,7 +217,7 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }); + }, SLOW_CLI_TIMEOUT); // NOTE: mutates review state (accepts all pending) — keep after review --json test it('review --batch accept --json validates against review-batch.schema.json', async () => { @@ -276,7 +277,7 @@ describe('CLI schema contract canaries', () => { expect(output.changed).toBe(false); expect(output.previous).toBe('done'); - }); + }, SLOW_CLI_TIMEOUT); it('unset --json validates against unset.schema.json', async () => { const listOutput = runCli(['nodes', '--json'], tempDir); @@ -297,7 +298,7 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }); + }, SLOW_CLI_TIMEOUT); it('view progress --json validates against view-progress.schema.json', async () => { // Set a status on a known node so progress has data @@ -317,7 +318,7 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }); + }, SLOW_CLI_TIMEOUT); it('view backlog:blocked --json validates against view-lens.schema.json', async () => { const schema = await loadSchema('view-lens.schema.json'); diff --git a/test/diff.test.js b/test/diff.test.js index 648c60b..3b14249 100644 --- a/test/diff.test.js +++ b/test/diff.test.js @@ -8,6 +8,8 @@ import { createEdge } from '../src/edges.js'; import { recordEpoch, getCurrentTick } from '../src/epoch.js'; import { diffSnapshots, computeDiff, parseDiffRefs, compareEdge, collectDiffPositionals } from '../src/diff.js'; +const COMPUTE_DIFF_TIMEOUT = 15_000; + /** * Create two separate graph instances in separate temp repos. * This is necessary because WarpGraph CRDTs merge all writers in a shared @@ -285,7 +287,7 @@ describe('computeDiff', () => { expect(diff.edges.added[0].type).toBe('documents'); expect(diff.stats.materializeMs.a).toBeGreaterThanOrEqual(0); expect(diff.stats.materializeMs.b).toBeGreaterThanOrEqual(0); - }); + }, COMPUTE_DIFF_TIMEOUT); it('throws descriptive error for ref with no epoch', async () => { await writeFile(join(tempDir, 'a.txt'), 'a'); @@ -325,7 +327,7 @@ describe('computeDiff', () => { expect(diff.to.nearest).toBe(true); expect(diff.nodes.added).toContain('task:c'); - }); + }, COMPUTE_DIFF_TIMEOUT); it('returns empty diff when both refs resolve to same tick', async () => { const graph = await initGraph(tempDir); @@ -349,7 +351,7 @@ describe('computeDiff', () => { expect(diff.edges.total).toBeNull(); expect(diff.stats.sameTick).toBe(true); expect(diff.stats.skipped).toBe(true); - }); + }, COMPUTE_DIFF_TIMEOUT); it('non-linear history: branch + merge with nearest fallback on one side', async () => { const graph = await initGraph(tempDir); @@ -387,7 +389,7 @@ describe('computeDiff', () => { expect(diff.to.nearest).toBe(true); expect(diff.nodes.added).toContain('task:c'); - }); + }, COMPUTE_DIFF_TIMEOUT); }); // ── parseDiffRefs ───────────────────────────────────────────────── diff --git a/test/graph.test.js b/test/graph.test.js index 3fe458a..709f573 100644 --- a/test/graph.test.js +++ b/test/graph.test.js @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; +import { getProp } from '../src/prop-bag.js'; describe('graph', () => { let tempDir; @@ -44,6 +45,6 @@ describe('graph', () => { expect(hasNode).toBe(true); const props = await graph2.getNodeProps('test-node'); - expect(props.get('label')).toBe('hello'); + expect(getProp(props, 'label')).toBe('hello'); }); }); diff --git a/test/import.test.js b/test/import.test.js index 0ee7657..951c6b2 100644 --- a/test/import.test.js +++ b/test/import.test.js @@ -6,6 +6,7 @@ import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; import { createEdge } from '../src/edges.js'; import { importFile } from '../src/import.js'; +import { getProp } from '../src/prop-bag.js'; describe('import', () => { let tempDir; @@ -283,8 +284,8 @@ nodes: expect(result.valid).toBe(true); const props = await graph.getNodeProps('task:auth'); - expect(props.get('status')).toBe('active'); - expect(props.get('priority')).toBe('high'); + expect(getProp(props, 'status')).toBe('active'); + expect(getProp(props, 'priority')).toBe('high'); }); it('rejects array-typed node properties', async () => { diff --git a/test/merge.test.js b/test/merge.test.js index d317a3a..fe1d354 100644 --- a/test/merge.test.js +++ b/test/merge.test.js @@ -6,6 +6,7 @@ import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; import { createEdge } from '../src/edges.js'; import { detectRepoIdentifier, mergeFromRepo } from '../src/merge.js'; +import { getProp } from '../src/prop-bag.js'; describe('merge', () => { let localDir; @@ -185,8 +186,8 @@ describe('merge', () => { await mergeFromRepo(localGraph, remoteDir, { repoName: 'other/repo' }); const props = await localGraph.getNodeProps('repo:other/repo:task:a'); - expect(props.get('status')).toBe('active'); - expect(props.get('priority')).toBe('high'); + expect(getProp(props, 'status')).toBe('active'); + expect(getProp(props, 'priority')).toBe('high'); }); }); }); diff --git a/test/prop-bag.test.js b/test/prop-bag.test.js new file mode 100644 index 0000000..01e9a0c --- /dev/null +++ b/test/prop-bag.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { getProp, getPropEntries, getPropSize, toPropObject } from '../src/prop-bag.js'; + +describe('prop-bag', () => { + it('supports Map-like property bags', () => { + const props = new Map([ + ['status', 'active'], + ['priority', 'high'], + ]); + + expect(getProp(props, 'status')).toBe('active'); + expect(getPropSize(props)).toBe(2); + expect(getPropEntries(props)).toEqual([ + ['status', 'active'], + ['priority', 'high'], + ]); + expect(toPropObject(props)).toEqual({ + status: 'active', + priority: 'high', + }); + }); + + it('supports object-like property bags', () => { + const props = { + status: 'active', + priority: 'high', + }; + + expect(getProp(props, 'status')).toBe('active'); + expect(getPropSize(props)).toBe(2); + expect(getPropEntries(props)).toEqual([ + ['status', 'active'], + ['priority', 'high'], + ]); + expect(toPropObject(props)).toEqual(props); + }); +}); From 3964fb89148ae90b016724725f914d088c61e908 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Mar 2026 08:27:04 -0700 Subject: [PATCH 3/5] docs: align git-warp upgrade guidance (#312) --- AGENTS.md | 4 ++-- CLAUDE.md | 4 ++-- CONTRIBUTING.md | 4 ++-- GUIDE.md | 2 +- TECH-PLAN.md | 2 +- docs/design/git-warp-upgrade-audit.md | 18 +++++++++++++----- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac14e9e..395d430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,8 +108,8 @@ Your space. Write about whatever you want: ## 5. TECH STACK REFERENCE -- **Runtime**: Node.js >= 20, ES modules -- **Core dependency**: `@git-stunts/git-warp` (local path, CRDT graph on Git) +- **Runtime**: Node.js >= 22, ES modules +- **Core dependency**: `@git-stunts/git-warp` (published package, CRDT graph on Git) - **Plumbing**: `@git-stunts/plumbing` (must be installed as direct dependency) - **Tests**: vitest - **Style**: Plain JS with JSDoc, no TypeScript diff --git a/CLAUDE.md b/CLAUDE.md index f603db0..7c51c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,8 +108,8 @@ Your space. Write about whatever you want: ## 5. TECH STACK REFERENCE -- **Runtime**: Node.js >= 20, ES modules -- **Core dependency**: `@git-stunts/git-warp` (local path, CRDT graph on Git) +- **Runtime**: Node.js >= 22, ES modules +- **Core dependency**: `@git-stunts/git-warp` (published package, CRDT graph on Git) - **Plumbing**: `@git-stunts/plumbing` (must be installed as direct dependency) - **Tests**: vitest - **Style**: Plain JS with JSDoc, no TypeScript diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c360f3e..a39a124 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,9 @@ GitHub milestones are not the primary planning surface for this repository. ## Prerequisites -- Node.js >= 20.0.0 +- Node.js >= 22.0.0 - Git -- A local clone of [`@git-stunts/git-warp`](https://github.com/nicktomlin/git-warp) (git-mind depends on it via local path) +- npm access to install published dependencies, including [`@git-stunts/git-warp`](https://github.com/nicktomlin/git-warp) ## Setup diff --git a/GUIDE.md b/GUIDE.md index 645e043..6ec4dcf 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -64,7 +64,7 @@ cd git-mind npm install ``` -git-mind depends on `@git-stunts/git-warp`, which is currently installed from a local path. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup details. +git-mind depends on the published `@git-stunts/git-warp` package. See [CONTRIBUTING.md](CONTRIBUTING.md) for current development setup details. ### Verify the installation diff --git a/TECH-PLAN.md b/TECH-PLAN.md index 0a70758..53c6dc6 100644 --- a/TECH-PLAN.md +++ b/TECH-PLAN.md @@ -20,7 +20,7 @@ The key insight: **Git is already a graph database.** It has an immutable object ## 2. Storage Layer: git-warp -git-mind delegates persistence to **@git-stunts/git-warp** (v10.3.2), a CRDT graph database that uses Git's object store as its backend. +git-mind delegates persistence to **@git-stunts/git-warp**. The specific version in use is defined in [package.json](package.json); this historical document originally described the `v10.3.2` era of the substrate. ### How data gets stored diff --git a/docs/design/git-warp-upgrade-audit.md b/docs/design/git-warp-upgrade-audit.md index 4073513..9dc0962 100644 --- a/docs/design/git-warp-upgrade-audit.md +++ b/docs/design/git-warp-upgrade-audit.md @@ -1,6 +1,6 @@ # git-warp Upgrade Audit -Status: draft for execution +Status: active execution on `feat/git-warp-upgrade-audit` Related: @@ -29,7 +29,7 @@ It is a boundary audit plus upgrade cycle. ## Context -Current local dependency state: +Cycle starting state: - declared in `package.json` as `^11.5.0` - locked and installed at `11.5.0` @@ -38,7 +38,11 @@ Live npm registry state checked on 2026-03-25: - latest published `@git-stunts/git-warp` version: `14.16.2` -That means Git Mind is currently three major versions behind the latest published package. +Chosen upgrade target for this cycle: + +- `14.16.2` + +That means this cycle upgrades Git Mind across three major versions of the substrate. This does not automatically mean "upgrade immediately no matter what." It does mean Git Mind should not keep expanding Hill 1 behavior without auditing the real upgrade surface first. @@ -106,9 +110,13 @@ The upgrade should pay special attention to: ## Known Mismatches Already Exposed -At least one contributor-facing document still says Git Mind depends on git-warp via a local path. +This cycle exposed one concrete runtime compatibility change: + +- `graph.getNodeProps()` now returns plain objects rather than `Map` instances + +Git Mind must treat node property bags as a compatibility boundary rather than assuming a single container shape. -That no longer matches the actual package metadata and should be corrected as part of this cycle. +This cycle also started with contributor-facing docs that still claimed git-warp was installed via local path. Those docs should be corrected as part of the upgrade. ## Acceptance Criteria From bc73cb0786c8d28f31ef27fb9196878e0926be8f Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Mar 2026 09:28:48 -0700 Subject: [PATCH 4/5] fix: harden git-warp v14 compatibility boundary (#312) --- src/cli/commands.js | 6 ++++ src/content.js | 1 + src/epoch.js | 20 ++++++++++-- src/export.js | 2 +- src/merge.js | 2 +- src/prop-bag.js | 6 ++-- src/review.js | 32 ++++++++++++++++--- test/contracts.integration.test.js | 51 ++++++++++++++++-------------- test/epoch.test.js | 10 ++++++ test/export.test.js | 26 +++++++++++++++ test/prop-bag.test.js | 51 ++++++++++++++++++++++++++++++ test/resolve-context.test.js | 16 ++++++++++ test/review.test.js | 33 +++++++++++++++++++ 13 files changed, 219 insertions(+), 37 deletions(-) diff --git a/src/cli/commands.js b/src/cli/commands.js index 5660263..58ab008 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -92,6 +92,12 @@ export async function resolveContext(cwd, envelope) { ); } const config = { match: getProp(propsMap, 'match') }; + if (typeof config.match !== 'string' || config.match.trim() === '') { + throw new Error( + `Observer '${observer}' is missing required 'match' property. ` + + `Set it with: git mind set observer:${observer} match 'prefix:*'`, + ); + } const expose = getProp(propsMap, 'expose'); const redact = getProp(propsMap, 'redact'); if (expose !== undefined) config.expose = expose; diff --git a/src/content.js b/src/content.js index 8cf973a..57b7631 100644 --- a/src/content.js +++ b/src/content.js @@ -26,6 +26,7 @@ const SIZE_KEY = '_content.size'; */ function decodeContent(content) { if (typeof content === 'string') return content; + if (Buffer.isBuffer(content)) return content.toString('utf-8'); return Buffer.from(content).toString('utf-8'); } diff --git a/src/epoch.js b/src/epoch.js index cd2cc61..470c516 100644 --- a/src/epoch.js +++ b/src/epoch.js @@ -70,10 +70,24 @@ export async function lookupEpoch(graph, commitSha) { const propsMap = await graph.getNodeProps(nodeId); if (!propsMap) return null; + const tick = getProp(propsMap, 'tick'); + const fullSha = getProp(propsMap, 'fullSha'); + const recordedAt = getProp(propsMap, 'recordedAt'); + + if ( + typeof tick !== 'number' || + typeof fullSha !== 'string' || + fullSha.length === 0 || + typeof recordedAt !== 'string' || + recordedAt.length === 0 + ) { + return null; + } + return { - tick: getProp(propsMap, 'tick'), - fullSha: getProp(propsMap, 'fullSha'), - recordedAt: getProp(propsMap, 'recordedAt'), + tick, + fullSha, + recordedAt, }; } diff --git a/src/export.js b/src/export.js index be80034..b3881a8 100644 --- a/src/export.js +++ b/src/export.js @@ -86,7 +86,7 @@ export async function exportGraph(graph, opts = {}) { // Include non-excluded edge properties if (edge.props) { - for (const [key, value] of Object.entries(edge.props)) { + for (const [key, value] of getPropEntries(edge.props)) { if (EXCLUDED_EDGE_PROPS.has(key)) continue; if (value !== undefined && value !== null) { entry[key] = value; diff --git a/src/merge.js b/src/merge.js index bd47dac..25080af 100644 --- a/src/merge.js +++ b/src/merge.js @@ -105,7 +105,7 @@ export async function mergeFromRepo(localGraph, remoteRepoPath, opts = {}) { // Copy selected edge properties if (edge.props) { - for (const [key, value] of Object.entries(edge.props)) { + for (const [key, value] of getPropEntries(edge.props)) { if (MERGE_EDGE_PROPS.has(key) && value !== undefined && value !== null) { patch.setEdgeProperty(qualifiedSource, qualifiedTarget, edge.label, key, value); } diff --git a/src/prop-bag.js b/src/prop-bag.js index 8665679..fa27874 100644 --- a/src/prop-bag.js +++ b/src/prop-bag.js @@ -19,7 +19,7 @@ */ export function getProp(props, key) { if (!props) return undefined; - if (typeof props.get === 'function') { + if (props instanceof Map) { return props.get(key); } return Object.prototype.hasOwnProperty.call(props, key) ? props[key] : undefined; @@ -33,7 +33,7 @@ export function getProp(props, key) { */ export function getPropEntries(props) { if (!props) return []; - if (typeof props.entries === 'function') { + if (props instanceof Map) { return [...props.entries()]; } return Object.entries(props); @@ -47,7 +47,7 @@ export function getPropEntries(props) { */ export function getPropSize(props) { if (!props) return 0; - if (typeof props.size === 'number') { + if (props instanceof Map) { return props.size; } return Object.keys(props).length; diff --git a/src/review.js b/src/review.js index d3b9bf9..3529e15 100644 --- a/src/review.js +++ b/src/review.js @@ -9,6 +9,8 @@ import { isLowConfidence } from './validators.js'; import { removeEdge, createEdge } from './edges.js'; import { getProp } from './prop-bag.js'; +const VALID_REVIEW_ACTIONS = new Set(['accept', 'reject', 'adjust', 'skip']); + /** * @typedef {object} PendingSuggestion * @property {string} source - Source node ID @@ -271,17 +273,37 @@ export async function getReviewHistory(graph, filter = {}) { if (!propsMap) continue; const action = getProp(propsMap, 'action'); + const source = getProp(propsMap, 'source'); + const target = getProp(propsMap, 'target'); + const edgeType = getProp(propsMap, 'edgeType'); + const confidence = getProp(propsMap, 'confidence'); + const timestamp = getProp(propsMap, 'timestamp'); + + if ( + !VALID_REVIEW_ACTIONS.has(action) || + typeof source !== 'string' || + source.length === 0 || + typeof target !== 'string' || + target.length === 0 || + typeof edgeType !== 'string' || + edgeType.length === 0 || + typeof confidence !== 'number' || + typeof timestamp !== 'number' + ) { + continue; + } + if (filter.action && action !== filter.action) continue; decisions.push({ id, action, - source: getProp(propsMap, 'source'), - target: getProp(propsMap, 'target'), - edgeType: getProp(propsMap, 'edgeType'), - confidence: getProp(propsMap, 'confidence'), + source, + target, + edgeType, + confidence, rationale: getProp(propsMap, 'rationale'), - timestamp: getProp(propsMap, 'timestamp'), + timestamp, reviewer: getProp(propsMap, 'reviewer'), }); } diff --git a/test/contracts.integration.test.js b/test/contracts.integration.test.js index d0e652e..1fd8793 100644 --- a/test/contracts.integration.test.js +++ b/test/contracts.integration.test.js @@ -15,7 +15,8 @@ import Ajv from 'ajv/dist/2020.js'; const BIN = join(import.meta.dirname, '..', 'bin', 'git-mind.js'); const SCHEMA_DIR = join(import.meta.dirname, '..', 'docs', 'contracts', 'cli'); const FIXTURE = join(import.meta.dirname, 'fixtures', 'echo-seed.yaml'); -const SLOW_CLI_TIMEOUT = 15_000; +const CLI_EXEC_TIMEOUT = 30_000; +const SLOW_CLI_TIMEOUT = 35_000; /** Load a schema by filename. */ async function loadSchema(name) { @@ -27,7 +28,7 @@ function runCli(args, cwd) { const stdout = execFileSync(process.execPath, [BIN, ...args], { cwd, encoding: 'utf-8', - timeout: 30_000, + timeout: CLI_EXEC_TIMEOUT, env: { ...process.env, NO_COLOR: '1' }, }); try { @@ -37,6 +38,8 @@ function runCli(args, cwd) { } } +const cliIt = (name, fn) => it(name, fn, SLOW_CLI_TIMEOUT); + /** Create a git commit in a repo and return the commit SHA. */ function gitCommit(cwd, filename, message) { execFileSync('git', ['add', filename], { cwd, stdio: 'ignore' }); @@ -94,7 +97,7 @@ describe('CLI schema contract canaries', () => { if (mergeDir) await rm(mergeDir, { recursive: true, force: true }); }); - it('status --json validates against status.schema.json', async () => { + cliIt('status --json validates against status.schema.json', async () => { const schema = await loadSchema('status.schema.json'); const output = runCli(['status', '--json'], tempDir); @@ -105,7 +108,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('nodes --json validates against node-list.schema.json', async () => { + cliIt('nodes --json validates against node-list.schema.json', async () => { const schema = await loadSchema('node-list.schema.json'); const output = runCli(['nodes', '--json'], tempDir); @@ -117,7 +120,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('nodes --id --json validates against node-detail.schema.json', async () => { + cliIt('nodes --id --json validates against node-detail.schema.json', async () => { // Get a known node from the list first const listOutput = runCli(['nodes', '--json'], tempDir); expect(listOutput.nodes.length, 'seed fixture should produce at least one node — check echo-seed.yaml import').toBeGreaterThan(0); @@ -132,9 +135,9 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }, SLOW_CLI_TIMEOUT); + }); - it('doctor --json validates against doctor.schema.json', async () => { + cliIt('doctor --json validates against doctor.schema.json', async () => { const schema = await loadSchema('doctor.schema.json'); const output = runCli(['doctor', '--json'], tempDir); @@ -145,7 +148,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('export --json validates against export-data.schema.json', async () => { + cliIt('export --json validates against export-data.schema.json', async () => { const schema = await loadSchema('export-data.schema.json'); const output = runCli(['export', '--json'], tempDir); @@ -157,7 +160,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('export --file --json validates against export-file.schema.json', async () => { + cliIt('export --file --json validates against export-file.schema.json', async () => { const schema = await loadSchema('export-file.schema.json'); const outPath = join(tempDir, 'export-test.yaml'); const output = runCli(['export', '--file', outPath, '--json'], tempDir); @@ -170,7 +173,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('import --dry-run --json validates against import.schema.json', async () => { + cliIt('import --dry-run --json validates against import.schema.json', async () => { const schema = await loadSchema('import.schema.json'); const output = runCli(['import', '--dry-run', FIXTURE, '--json'], tempDir); @@ -182,7 +185,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('review --json validates against review-list.schema.json', async () => { + cliIt('review --json validates against review-list.schema.json', async () => { const schema = await loadSchema('review-list.schema.json'); const output = runCli(['review', '--json'], tempDir); @@ -194,7 +197,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('at HEAD --json validates against at.schema.json', async () => { + cliIt('at HEAD --json validates against at.schema.json', async () => { const schema = await loadSchema('at.schema.json'); const output = runCli(['at', 'HEAD', '--json'], tempDir); @@ -206,7 +209,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('diff HEAD~1..HEAD --json validates against diff.schema.json', async () => { + cliIt('diff HEAD~1..HEAD --json validates against diff.schema.json', async () => { const schema = await loadSchema('diff.schema.json'); const output = runCli(['diff', 'HEAD~1..HEAD', '--json'], tempDir); @@ -217,10 +220,10 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }, SLOW_CLI_TIMEOUT); + }); // NOTE: mutates review state (accepts all pending) — keep after review --json test - it('review --batch accept --json validates against review-batch.schema.json', async () => { + cliIt('review --batch accept --json validates against review-batch.schema.json', async () => { const schema = await loadSchema('review-batch.schema.json'); const output = runCli(['review', '--batch', 'accept', '--json'], tempDir); @@ -233,7 +236,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('merge --dry-run --json validates against merge.schema.json', async () => { + cliIt('merge --dry-run --json validates against merge.schema.json', async () => { const schema = await loadSchema('merge.schema.json'); const output = runCli(['merge', '--from', mergeDir, '--repo-name', 'test/merge-repo', '--dry-run', '--json'], tempDir); @@ -246,7 +249,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('set --json validates against set.schema.json', async () => { + cliIt('set --json validates against set.schema.json', async () => { // First, find a known node to set a property on const listOutput = runCli(['nodes', '--json'], tempDir); expect(listOutput.nodes.length).toBeGreaterThan(0); @@ -266,7 +269,7 @@ describe('CLI schema contract canaries', () => { expect(validate(output), JSON.stringify(validate.errors)).toBe(true); }); - it('set --json returns changed: false on idempotent re-set', async () => { + cliIt('set --json returns changed: false on idempotent re-set', async () => { const listOutput = runCli(['nodes', '--json'], tempDir); const knownId = listOutput.nodes[0]; @@ -277,9 +280,9 @@ describe('CLI schema contract canaries', () => { expect(output.changed).toBe(false); expect(output.previous).toBe('done'); - }, SLOW_CLI_TIMEOUT); + }); - it('unset --json validates against unset.schema.json', async () => { + cliIt('unset --json validates against unset.schema.json', async () => { const listOutput = runCli(['nodes', '--json'], tempDir); const knownId = listOutput.nodes[0]; @@ -298,9 +301,9 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }, SLOW_CLI_TIMEOUT); + }); - it('view progress --json validates against view-progress.schema.json', async () => { + cliIt('view progress --json validates against view-progress.schema.json', async () => { // Set a status on a known node so progress has data const listOutput = runCli(['nodes', '--json'], tempDir); const taskNode = listOutput.nodes.find(n => n.startsWith('task:')); @@ -318,9 +321,9 @@ describe('CLI schema contract canaries', () => { const validate = ajv.compile(schema); expect(validate(output), JSON.stringify(validate.errors)).toBe(true); - }, SLOW_CLI_TIMEOUT); + }); - it('view backlog:blocked --json validates against view-lens.schema.json', async () => { + cliIt('view backlog:blocked --json validates against view-lens.schema.json', async () => { const schema = await loadSchema('view-lens.schema.json'); const output = runCli(['view', 'backlog:blocked', '--json'], tempDir); diff --git a/test/epoch.test.js b/test/epoch.test.js index 66a3b19..740fbd4 100644 --- a/test/epoch.test.js +++ b/test/epoch.test.js @@ -57,6 +57,16 @@ describe('epoch', () => { expect(epoch).toBeNull(); }); + it('returns null for a malformed epoch node', async () => { + const patch = await graph.createPatch(); + patch.addNode('epoch:abc123def456'); + patch.setProperty('epoch:abc123def456', 'tick', 42); + await patch.commit(); + + const epoch = await lookupEpoch(graph, 'abc123def456'); + expect(epoch).toBeNull(); + }); + it('uses first 12 chars of SHA as node ID', async () => { await recordEpoch(graph, 'abc123def456789', 10); const nodes = await graph.getNodes(); diff --git a/test/export.test.js b/test/export.test.js index 6751ed3..15ad0e7 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -71,6 +71,32 @@ describe('export', () => { expect(edge.importedAt).toBeUndefined(); }); + it('exports edge properties when edge props are Map-like', async () => { + const fakeGraph = { + getNodes: async () => ['task:a', 'spec:b'], + getNodeProps: async () => null, + getEdges: async () => [{ + from: 'task:a', + to: 'spec:b', + label: 'implements', + props: new Map([ + ['confidence', 0.8], + ['rationale', 'Map-like props'], + ['createdAt', 'skip-me'], + ]), + }], + }; + + const data = await exportGraph(fakeGraph); + expect(data.edges).toEqual([{ + source: 'task:a', + target: 'spec:b', + type: 'implements', + confidence: 0.8, + rationale: 'Map-like props', + }]); + }); + // ── System node exclusion ──────────────────────────────────── it('excludes decision: nodes', async () => { diff --git a/test/prop-bag.test.js b/test/prop-bag.test.js index 01e9a0c..f75c72a 100644 --- a/test/prop-bag.test.js +++ b/test/prop-bag.test.js @@ -34,4 +34,55 @@ describe('prop-bag', () => { ]); expect(toPropObject(props)).toEqual(props); }); + + it('handles null and undefined inputs safely', () => { + expect(getProp(null, 'missing')).toBeUndefined(); + expect(getProp(undefined, 'missing')).toBeUndefined(); + expect(getPropEntries(null)).toEqual([]); + expect(getPropEntries(undefined)).toEqual([]); + expect(getPropSize(null)).toBe(0); + expect(getPropSize(undefined)).toBe(0); + expect(toPropObject(null)).toEqual({}); + expect(toPropObject(undefined)).toEqual({}); + }); + + it('returns undefined for missing keys', () => { + expect(getProp(new Map(), 'missing')).toBeUndefined(); + expect(getProp({}, 'missing')).toBeUndefined(); + }); + + it('ignores inherited properties on plain objects', () => { + const proto = { inherited: 'bad' }; + const props = Object.create(proto); + props.own = 'good'; + + expect(getProp(props, 'inherited')).toBeUndefined(); + expect(getProp(props, 'own')).toBe('good'); + expect(getPropEntries(props)).toEqual([['own', 'good']]); + }); + + it('handles empty containers', () => { + expect(getPropSize(new Map())).toBe(0); + expect(getPropSize({})).toBe(0); + expect(getPropEntries(new Map())).toEqual([]); + expect(getPropEntries({})).toEqual([]); + }); + + it('treats plain objects with helper-like keys as plain objects', () => { + const props = { + get: 'not-a-function', + entries: 'not-a-function', + size: 0, + status: 'active', + }; + + expect(getProp(props, 'status')).toBe('active'); + expect(getPropSize(props)).toBe(4); + expect(getPropEntries(props)).toEqual([ + ['get', 'not-a-function'], + ['entries', 'not-a-function'], + ['size', 0], + ['status', 'active'], + ]); + }); }); diff --git a/test/resolve-context.test.js b/test/resolve-context.test.js index c731ff0..de65e5f 100644 --- a/test/resolve-context.test.js +++ b/test/resolve-context.test.js @@ -161,6 +161,22 @@ describe('resolveContext()', () => { "Observer 'missing-profile' not found", ); }); + + it('throws when observer node is missing required match', async () => { + const propsMap = new Map([['expose', ['status']]]); + const fakeGraph = makeFakeGraph({ + getNodeProps: vi.fn().mockResolvedValue(propsMap), + observer: vi.fn(), + }); + initGraph.mockResolvedValue(fakeGraph); + + const envelope = createContext({ observer: 'broken-profile' }); + + await expect(resolveContext('/repo', envelope)).rejects.toThrow( + "Observer 'broken-profile' is missing required 'match' property", + ); + expect(fakeGraph.observer).not.toHaveBeenCalled(); + }); }); describe('resolvedContext fields', () => { diff --git a/test/review.test.js b/test/review.test.js index bdfd553..ac984be 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -170,6 +170,39 @@ describe('review', () => { expect(rejects[0].action).toBe('reject'); }); + it('skips malformed decision nodes in review history', async () => { + const patch = await graph.createPatch(); + patch.addNode('decision:missing-source'); + patch.setProperty('decision:missing-source', 'action', 'accept'); + patch.setProperty('decision:missing-source', 'target', 'spec:b'); + patch.setProperty('decision:missing-source', 'edgeType', 'implements'); + patch.setProperty('decision:missing-source', 'confidence', 0.8); + patch.setProperty('decision:missing-source', 'timestamp', 100); + + patch.addNode('decision:bad-action'); + patch.setProperty('decision:bad-action', 'action', 'maybe'); + patch.setProperty('decision:bad-action', 'source', 'task:a'); + patch.setProperty('decision:bad-action', 'target', 'spec:b'); + patch.setProperty('decision:bad-action', 'edgeType', 'implements'); + patch.setProperty('decision:bad-action', 'confidence', 0.8); + patch.setProperty('decision:bad-action', 'timestamp', 200); + + patch.addNode('decision:valid'); + patch.setProperty('decision:valid', 'action', 'reject'); + patch.setProperty('decision:valid', 'source', 'task:c'); + patch.setProperty('decision:valid', 'target', 'spec:d'); + patch.setProperty('decision:valid', 'edgeType', 'documents'); + patch.setProperty('decision:valid', 'confidence', 0.2); + patch.setProperty('decision:valid', 'timestamp', 300); + + await patch.commit(); + + const history = await getReviewHistory(graph); + expect(history).toHaveLength(1); + expect(history[0].id).toBe('decision:valid'); + expect(history[0].action).toBe('reject'); + }); + // ── adjustSuggestion: preserve original confidence ───────── it('preserves original confidence when adjustment omits confidence', async () => { From 4d88845aac38eb7399def1066dab845406c945be Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 25 Mar 2026 09:28:53 -0700 Subject: [PATCH 5/5] docs: record git-warp upgrade review fixes (#312) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253db6f..c1960cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **git-warp v14 compatibility hardening** — Explicit `Map` detection in property-bag helpers, observer `match` validation, epoch/review malformed-record filtering, and edge-property normalization for export/merge (#312) +- **CLI contract canary timeout consistency** — Unified the CLI integration harness around a shared timeout budget and added edge-case coverage for the git-warp compatibility boundary (#312) + ## [5.0.0] - 2026-02-25 ### Breaking