|
| 1 | +# Eliminating the Node engine |
| 2 | + |
| 3 | +> Status: **design / deferred** (2026-06-11). Do the work on a feature branch off `jcamp` |
| 4 | +> (see memory `branch-off-jcamp`). No commits without explicit approval. |
| 5 | +
|
| 6 | +## Goal |
| 7 | + |
| 8 | +Remove every place this tool needs **Node as its engine**, so a .NET-only or zero-Node |
| 9 | +machine produces byte-identical release output. Keep — and lean harder on — **Node as |
| 10 | +plumbing** (the package manager, the publisher), which is the legitimate "call out to |
| 11 | +Node-based tools" and is only present when you're actually in a JS repo. |
| 12 | + |
| 13 | +This is the no-regret shape for the eventual zero-dependency Go port: the engine |
| 14 | +(parse changesets → decide bumps + cascade → render changelog → stamp manifest) runs |
| 15 | +natively and identically across ecosystems; ecosystem-specific work sits behind a thin |
| 16 | +adapter that delegates to the native package manager. See memory `project-north-star`. |
| 17 | + |
| 18 | +## Two kinds of "Node", opposite treatment |
| 19 | + |
| 20 | +| Kind | Examples | Treatment | |
| 21 | +|---|---|---| |
| 22 | +| **Engine** — computes *what the release is* | `npx changeset version`, prettier-as-required-formatter | **Eliminate** → native C# | |
| 23 | +| **Plumbing** — ecosystem mechanics | `pnpm list`/`npm query`/`yarn workspaces`, `npm publish` | **Keep**, and use *more* (graph source) | |
| 24 | + |
| 25 | +The pivot: Workstream **B** *adds* an acceptable package-manager call that *enables removing* |
| 26 | +the `npx changeset` engine call in Workstream **C**. |
| 27 | + |
| 28 | +## Where the engine already is native |
| 29 | + |
| 30 | +The planner core has **zero `.csproj` coupling** already: |
| 31 | + |
| 32 | +- `ReleasePlanner.Assemble(ReleaseInputs) → List<ModuleChangelog>` — pure transform. |
| 33 | +- `ChangelogGenerator` — cascade + linked/fixed/lockstep grouping, generic over the package model. |
| 34 | +- `Semver` — node-semver bump rules, fully ecosystem-agnostic. |
| 35 | +- `ChangelogFileWriter` — markdown generation mirroring `@changesets` `getReleaseLine`, no ecosystem details. |
| 36 | + |
| 37 | +Node survives the engine in exactly three spots, one per workstream below. |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Workstream A — Native changelog formatter ("native prettier") |
| 42 | + |
| 43 | +**Today:** `src/Changesets/Commands/Version/Helpers/ChangelogFormatter.cs` shells out to |
| 44 | +`pnpm exec prettier` / `dprint` / `deno` / etc. Default `format:false`, so most .NET repos |
| 45 | +get no formatting. |
| 46 | + |
| 47 | +**Target:** a hand-rolled, dependency-free, line-based formatter, **default-on**, matching the |
| 48 | +prettier / dprint / deno trio byte-for-byte. Full rule spec already captured in memory |
| 49 | +`changelog-formatter-native-design` (prettier 3.8.4 source rules: heading `#`+space; block |
| 50 | +spacing via loose/tight `node.spread` detection; `-` markers + 2-space child indent; verbatim |
| 51 | +code fences; trailing-whitespace trim at every line break; single trailing newline; table |
| 52 | +column alignment). |
| 53 | + |
| 54 | +**Steps** |
| 55 | +1. Implement `NativeChangelogFormatter` — line-based with code-fence open/close state tracking. |
| 56 | + Failure mode is "table not column-aligned" (cosmetic), never Markdig-style data corruption |
| 57 | + (Markdig rejected — corrupts pipe tables; see the memory). |
| 58 | +2. Table column-alignment is the one genuinely hard rule — ship it last as a documented edge; |
| 59 | + changeset summaries rarely contain tables. |
| 60 | +3. Config wiring: |
| 61 | + - `format:"native"` → native formatter (**new default**) |
| 62 | + - `format:"prettier"|"dprint"|"deno"|...` → keep the existing shell-out escape hatch |
| 63 | + - `format:false` → explicit opt-out |
| 64 | +4. **Tests:** dual golden sets regenerated from pinned `@changesets@3.0.0-next.5` (node must be |
| 65 | + present to regenerate): |
| 66 | + - `format:false` (raw assembly) — current set; `Normalize` keeps trimming trailing ws. |
| 67 | + - `format:<prettier>` (formatted) — **new set**; `Normalize` must **not** trim, or it won't |
| 68 | + actually exercise the formatter. |
| 69 | + - Add a gnarly fixture: code fence + nested list + blockquote + table to lock the known edges. |
| 70 | + |
| 71 | +**Independence:** lands first, standalone. Becomes load-bearing once C makes our engine write |
| 72 | +JS changelogs too (they must come out matching prettier without shelling out). |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## Workstream B — Package manager as graph source ("pm as graph generator") |
| 77 | + |
| 78 | +**Today:** `src/Changesets/Shared/NodePackagesRepository.cs` hand-parses `pnpm-workspace.yaml` |
| 79 | +and `package.json` `workspaces` globs, and extracts only package **names** — no internal |
| 80 | +dependency graph, no version ranges, no dep types. That's exactly what C needs. |
| 81 | + |
| 82 | +**Target:** when a JS workspace is present, get the graph as **data** from the package manager |
| 83 | +(per north star: delegate to the pm, *never* to `@changesets`). Each pm can emit the workspace |
| 84 | +graph as JSON: |
| 85 | + |
| 86 | +- **yarn** — emits the *internal edges* directly: |
| 87 | + - Berry: `yarn workspaces list -v --json` → each workspace plus a `workspaceDependencies` array. |
| 88 | + - Classic: `yarn workspaces info --json` → keyed by package, each with `workspaceDependencies` |
| 89 | + + `mismatchedWorkspaceDependencies`. |
| 90 | +- **pnpm**: `pnpm list -r --depth -1 --json` → every workspace package with its |
| 91 | + `dependencies` / `devDependencies` / `peerDependencies` and resolved versions. |
| 92 | +- **npm** (8.16+): `npm query ".workspace"` → JSON of each workspace with full dep metadata; |
| 93 | + `npm ls --json --all` for the resolved tree. |
| 94 | + |
| 95 | +**Nuance:** only **yarn** literally names the *internal* edges. For **npm/pnpm** the pm gives |
| 96 | +(workspace nodes + declared deps + resolved versions), and you form the internal edges by |
| 97 | +intersecting declared-dep names against the set of workspace names. That intersection is exactly |
| 98 | +what `@manypkg/get-packages` (what `@changesets` itself uses) does — cheap and deterministic, not |
| 99 | +resolution guesswork. So "the pm gives us the graph" holds for all three; for npm/pnpm it's |
| 100 | +"pm gives nodes + declared edges, we keep the ones pointing at other workspace members." |
| 101 | + |
| 102 | +**Steps** |
| 103 | +1. New `JsPackagesRepository` returning rich `JsPackage` records: `Name`, `Version`, |
| 104 | + `PackageJsonPath`, and **internal dependencies with dep type (deps/dev/peer/optional) and |
| 105 | + version range**. (Strictly more than today's `NodePackagesRepository`, which extracts only |
| 106 | + names and sees no edges, ranges, or dep types.) |
| 107 | +2. Detect the pm from the lockfile — reuse the logic already in |
| 108 | + `ChangelogFormatter.ResolvePackageManagerExecute`. |
| 109 | +3. Keep the existing native glob walker as a **fallback** when no pm is installed, so name-only |
| 110 | + operations (e.g. `status`) still degrade gracefully. Graph-dependent operations require the pm. |
| 111 | + |
| 112 | +**Why this isn't a regression:** only the pm authoritatively resolves workspaces (`workspace:*` |
| 113 | +protocols, overrides, hoisting). Reimplementing that natively is the maintenance tar pit the |
| 114 | +north star warns against. The pm is an allowed call. |
| 115 | + |
| 116 | +--- |
| 117 | + |
| 118 | +## Workstream C — Port JS versioning from `@changesets` (the hard one) |
| 119 | + |
| 120 | +**Today:** JS versioning is **entirely delegated**: |
| 121 | +- no `.csproj` found → `npx changeset version` |
| 122 | + (`VersionChangesetCommand.cs:98-106`) |
| 123 | +- interop mode → native .NET + `npx changeset version` for the JS side |
| 124 | + (`VersionChangesetCommand.cs:195-198`) |
| 125 | +- all via `NodeChangesetService.cs` / `NodeDelegation.cs`. |
| 126 | + |
| 127 | +**Target:** route `JsPackage` (from B) through the **same** `ReleasePlanner` the .NET path uses, |
| 128 | +stamp `package.json` natively, generate changelogs natively. Drop the `npx changeset` engine call. |
| 129 | + |
| 130 | +**Steps** |
| 131 | + |
| 132 | +1. **Abstraction seam.** Introduce `IPackage` (`Name`, `Version`, `ReferencedDependencies`, |
| 133 | + `ManifestPath`, `PackageTitle`). `CsProject` and `JsPackage` both implement it. Make |
| 134 | + `ReleaseInputs.Projects` an `IReadOnlyList<IPackage>`. Generalize the single coupled field |
| 135 | + `ModuleChangelog.ModuleCsProjFilePath` → `ModuleManifestPath`. Planner, grouping, and changelog |
| 136 | + writer need **no logic changes** — they're already agnostic. |
| 137 | + |
| 138 | +2. **Range-aware cascade — the genuinely new code.** Today the cascade hardcodes "dependents |
| 139 | + always patch-bump" (`ChangelogGenerator.cs:283`), which is correct for `.csproj`'s rangeless |
| 140 | + `ProjectReference` but **wrong for npm**. |
| 141 | + |
| 142 | + **Decision (2026-06-11): match `@changesets` exactly.** Port the real rules: |
| 143 | + - a dependent bumps only when the dependency's new version falls **out of its range**; |
| 144 | + - `updateInternalDependencies` (patch/minor) threshold; |
| 145 | + - `onlyUpdatePeerDependentsWhenOutOfRange` for peer deps. |
| 146 | + |
| 147 | + Framed for "one process" (north star): this is **one** range-aware algorithm parameterized by |
| 148 | + "does this dependency edge carry a range?". The current .NET "always patch" is its degenerate |
| 149 | + *rangeless* special case — `.csproj` `ProjectReference` is always out-of-range, so it always |
| 150 | + patch-bumps. So matching `@changesets` and staying uniform converge: implement the range-aware |
| 151 | + algorithm once; `.csproj` falls out of it as the no-range case. Make the cascade strategy a |
| 152 | + property of the adapter (range model), not a fork in the planner. |
| 153 | + |
| 154 | +3. **Write-back.** `JsPackage` version write — stamp `version` in `package.json`, **and rewrite |
| 155 | + internal dependency ranges** (`"^1.0.0"` → `"^1.1.0"`). .NET has no analog (`UpdateInternalDependencies` |
| 156 | + only controls dependent *bump size*, never a constraint). New but mechanical: System.Text.Json |
| 157 | + read + minimal-diff write that preserves existing formatting. |
| 158 | + |
| 159 | + > **Go-port note.** This is the one step where Go's stdlib is *worse* than .NET's for this job. |
| 160 | + > Go's `encoding/json` **reorders keys and reformats on marshal**, so a naive round-trip nukes the |
| 161 | + > user's formatting and key order — unacceptable for editing someone's `package.json`. Use a |
| 162 | + > surgical, format-preserving editor (e.g. `tidwall/sjson`) to set only the `version` and |
| 163 | + > dep-range fields **in place**, leaving the rest byte-identical. Same caution for other manifests |
| 164 | + > the adapter writes: `Cargo.toml` / `pyproject.toml` need a format-preserving TOML editor |
| 165 | + > (`toml-edit`-equivalent), **not** marshal-and-rewrite. .NET's `System.Text.Json` gives controlled |
| 166 | + > edits today; don't assume the Go equivalent round-trips cleanly — it doesn't. |
| 167 | +
|
| 168 | +4. **Retire engine delegation.** Remove `AutoRunNode` / `NodeChangesetService` / `NodeDelegation` |
| 169 | + from the version path (and status / publish). Keep `npm publish` for the publish step (plumbing, |
| 170 | + allowed). Optionally keep `nodeChangesetCommand` as a **deprecated** v0 escape hatch — no longer |
| 171 | + the destination. |
| 172 | + |
| 173 | +5. **Parity net (flip it).** Parity tests already run against `@changesets@3.0.0-next.5`. Today they |
| 174 | + assert *we delegate to* `@changesets`; flip them to assert *our native output matches* |
| 175 | + `@changesets`' `package.json` + `CHANGELOG.md` byte-for-byte. Strongest possible oracle for this |
| 176 | + port — and the oracle that drives step 2. |
| 177 | + |
| 178 | +**Ordering:** C depends on B (real graph + ranges) and pairs with A (our engine now writes JS |
| 179 | +changelogs, so the native formatter must format them for parity). |
| 180 | + |
| 181 | +--- |
| 182 | + |
| 183 | +## Sequencing & risk |
| 184 | + |
| 185 | +| Phase | Work | Unblocks | Risk | |
| 186 | +|---|---|---|---| |
| 187 | +| 1 | **A** native formatter + dual goldens | parity once C lands | Low — spec fully extracted; table alignment deferrable | |
| 188 | +| 2 | **B** pm graph source + `JsPackage` | C | Med — per-pm CLI quirks, JSON shape drift | |
| 189 | +| 3 | **C.1** `IPackage` seam + generalize `ModuleChangelog` | C.2–C.5 | Low — planner already agnostic, mechanical | |
| 190 | +| 4 | **C.2** range-aware cascade (match `@changesets`) | — | **High** — real `@changesets` semantics; parity goldens are the oracle | |
| 191 | +| 5 | **C.3** `package.json` write-back + range rewrite | — | Med — formatting-preserving JSON edits | |
| 192 | +| 6 | **C.4** retire engine delegation; flip parity tests | done | Low | |
| 193 | + |
| 194 | +## End state |
| 195 | + |
| 196 | +Node is required **only** to: |
| 197 | +- run the package manager when you're in a JS repo (graph resolution + `npm publish`); |
| 198 | +- optionally run a named external formatter if you opt out of the native one. |
| 199 | + |
| 200 | +The engine — parse changesets, decide bumps + cascade, render changelog, stamp manifest — runs in |
| 201 | +native C# identically across `.NET` and JS. That is the "one process across all ecosystems" target, |
| 202 | +and the no-regret shape for the Go port. |
| 203 | + |
| 204 | +## Related memory |
| 205 | + |
| 206 | +`project-north-star`, `changelog-formatter-native-design`, `branch-off-jcamp`, |
| 207 | +`no-commit-without-approval`, `local-first-release-philosophy`. |
0 commit comments