Skip to content

Commit 0589953

Browse files
committed
Merge branch 'feat/native-changelog-formatter' into jcamp
2 parents 547365e + 4f48194 commit 0589953

7 files changed

Lines changed: 1226 additions & 17 deletions

File tree

.claude/PREFERENCES.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Working with John on net-changesets
2+
3+
## Approval & safety (hard rules)
4+
- **Never commit, push, or open/close/comment on a PR without explicit approval for that exact step.** Build/test/edit/stage freely, then stop and ask.
5+
- **Never do anything that touches the upstream repo (`solarwinds/net-changesets`) without explicit approval** — a GitHub "draft PR" still lives on their repo and notifies; it is *not* a private preview.
6+
- **"Draft a PR / let me review"** means: show the PR title + body as **text in chat** first. To actually open one, use `gh pr create --web` (pushes the branch, opens a *prefilled* compose page John sends himself). Don't create a GitHub draft PR on upstream.
7+
8+
## Upstream PRs
9+
- **Narrow and precise — one concern per PR.** Split extensive work into separate PRs (e.g., .NET 10 adoption and the Spectre upgrade were two PRs).
10+
- **Plain, hand-written prose.** No emoji section headers, no "Summary/Test plan" scaffolding, no "Generated with Claude Code" footer. **Commit messages plain too — no Co-Authored-By trailer.**
11+
- **Fix things properly, don't suppress** — honor the maintainers' conventions (e.g., fix analyzer findings rather than `NoWarn` them; don't trample their `AnalysisLevel`-tracks-TFM intent).
12+
- **Keep the `SolarWinds.Changesets` identity intact** on upstream branches — no rebrand there.
13+
14+
## Code & comments
15+
- **Don't add explanatory comments to code**, especially config files. Let the code speak.
16+
17+
## Git / branch model
18+
- `origin` = the fork (`jcamp-code`); `upstream` = `solarwinds` (read-only, push disabled).
19+
- `main` = clean mirror of `upstream/main` (branch-protected, no direct pushes).
20+
- Upstreamable fixes: each on its own topic branch **off `upstream/main`** (independent, not stacked) → its own PR. Sign-off with `git commit -s` when they have a DCO.
21+
- `jcamp` = the product/release line; merge features into it.
22+
- `johns-wip/` = local scratch, gitignored.
23+
24+
## Project context
25+
- **Goal:** 100% consistency with `@changesets/cli` + polyglot (Node + .NET in one `.changeset/`) support.
26+
- **Own distribution:** publish as NuGet tool **+** a thin npm `@jcamp/changesets` wrapper; default command **`dn-changeset`** (configurable, with an opt-in `changeset` alias).
27+
- **Design docs:** `FORK.md` (strategy/decisions), `demo/COMPARISON.md`, `demo/POLYGLOT.md`, `johns-wip/ROADMAP.md`, `johns-wip/GIT-WORKFLOW.md`.
28+
29+
## How to operate
30+
- **Verify empirically** — test/inspect rather than guess (e.g., probing the Spectre package split instead of assuming versions).
31+
- Expect deep, probing questions and mid-task course corrections; keep work **focused and precise**.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Pluggable changelog generators
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+
Let a changelog generator be **swapped out** without forking the tool, so a repo can render
9+
release notes its own way (different grouping, emoji, locale, JSON-for-a-website, etc.) while the
10+
engine still owns *what the release is* (bumps, cascade, version stamping).
11+
12+
The mechanism is the one this tool already lives by: **delegate to an external command over a
13+
defined I/O contract**, exactly like it shells out to `changeset` / `git` / `gh`
14+
(see `eliminate-node-engine-design.md`, `project-north-star`). A changelog plugin is "just another
15+
CLI we call." No Go `plugin` `.so`, no gRPC, no WASM — a subprocess with JSON on stdin and rendered
16+
text on stdout.
17+
18+
**Non-negotiable design rule: dogfood it.** The built-in formatter is *the reference implementation
19+
of the same contract*, not a privileged special case the contract was bolted onto afterward. See
20+
"Dogfooding" below — this is what keeps the contract honest.
21+
22+
## Why subprocess + JSON (and not the alternatives)
23+
24+
| Option | Verdict |
25+
|---|---|
26+
| Go `plugin` (`.so`) | **No.** Linux/macOS only, exact-version/flag/dep lockstep, no unload. Trap. |
27+
| HashiCorp `go-plugin` (gRPC) | Overkill. Plugins here are one-shot pure functions, not long-lived chatty servers. Adds gRPC/protobuf deps. |
28+
| WASM (`wazero`, pure-Go zero-dep) | Reserve for **untrusted** community plugins needing a sandbox. Forces a WASM toolchain + host/guest ABI on authors. Not worth it for trusted, local generators. |
29+
| **Subprocess + JSON contract** | **Yes.** Zero new deps, cross-platform, language-agnostic (shell/Node/Rust/anything), and identical to the delegation model already in use. |
30+
31+
A changelog generator is a pure function — `(changes + version + package) → rendered notes` — which
32+
is the ideal shape for a stateless one-shot subprocess.
33+
34+
## The contract
35+
36+
### Invocation
37+
38+
The engine spawns the configured generator command once **per package being released** (matching the
39+
per-`ModuleChangelog` loop in `ChangelogFileWriter.GenerateChangelogFilesAsync`). It writes a single
40+
JSON object to the plugin's **stdin** and reads the result from **stdout**.
41+
42+
- **stdout** → the rendered release entry (the block inserted under the package's `# Title`, i.e. the
43+
`## <version>` section). The engine still owns file placement, the `# Title` header, and insertion
44+
position — the plugin renders *one entry*, not the whole file.
45+
- **stderr** → surfaced to the user as plugin diagnostics (never parsed).
46+
- **exit 0** → success. **Non-zero** → abort the release and report the plugin's stderr (consistent
47+
with hook failure semantics in `release-command-design.md`).
48+
49+
One-shot per package keeps plugins trivially stateless and parallelizable, and means a crashing
50+
plugin can't corrupt more than the entry it was rendering.
51+
52+
### Input schema (stdin)
53+
54+
Versioned from day one — this is the thing we can't break later.
55+
56+
```jsonc
57+
{
58+
"apiVersion": 1,
59+
"package": {
60+
"name": "Buoy.Web.Api", // ModuleName
61+
"displayName": "Buoy Web API", // PackageTitle ?? ModuleName
62+
"currentVersion": "1.3.2", // CurrentVersion
63+
"newVersion": "1.4.0" // ResolvedVersion (already includes prerelease/snapshot suffix)
64+
},
65+
"bump": "minor", // HighestBumpType: major | minor | patch
66+
"changes": [
67+
{
68+
"bump": "minor", // per-change BumpType
69+
"summary": "Add buoy telemetry endpoint\n\nLong-form body kept verbatim.",
70+
// Enrichment the engine already resolves for the built-in renderer:
71+
"commit": "a1b2c3d", // nullable
72+
"pr": 412, // nullable
73+
"author": "jcamp" // nullable
74+
}
75+
],
76+
"dependencyUpdates": [ // the "Updated dependencies" section, pre-resolved
77+
{ "name": "Buoy.Core", "version": "2.1.0" }
78+
],
79+
"context": { // mirrors release-command ${...} context where meaningful
80+
"tag": "Buoy.Web.Api@1.4.0",
81+
"repoRoot": "/abs/path"
82+
}
83+
}
84+
```
85+
86+
Field shape is derived directly from `ModuleChangelog` (`ModuleChangelog.cs`) and the
87+
`getReleaseLine` enrichment in `ChangelogReleaseLine.cs`, so no new data needs to be computed — the
88+
engine already has all of it at render time.
89+
90+
### Output (stdout)
91+
92+
Plain UTF-8 text: the rendered entry, **excluding** the `# Title` file header. The engine trims a
93+
single trailing newline and inserts it at the documented position (after line 2 for an existing file;
94+
as the body for a new one — see `ChangelogFileWriter`). Whether the output is post-processed by the
95+
native formatter (`changelog-formatter-native-design`) is controlled by the existing `format` setting;
96+
default behavior TBD (see Open questions).
97+
98+
### Versioning rule
99+
100+
`apiVersion` is an integer. The engine sends the highest version it speaks; a plugin that doesn't
101+
recognize it must exit non-zero with a clear message rather than guess. Additive fields don't bump the
102+
version; removing/renaming a field or changing its meaning does.
103+
104+
## Discovery & configuration
105+
106+
Configured in `.changeset/release.jsonc` (JSONC; see `release-command-design.md`):
107+
108+
```jsonc
109+
{
110+
"changelog": {
111+
"generator": "default" // built-in (see Dogfooding) — the default
112+
// "generator": "changeset-changelog-keepachangelog" // resolved on $PATH by convention
113+
// "generator": "./scripts/our-notes.mjs" // explicit path
114+
// "generator": { "command": ["node", "gen.js"], "args": ["--emoji"] } // full form
115+
}
116+
}
117+
```
118+
119+
Resolution order for a string value:
120+
1. `"default"` (and any other built-in id) → in-process reference generator.
121+
2. A path (contains `/` or `\`, or starts with `.`) → executed directly.
122+
3. A bare name → looked up on `$PATH` by the `changeset-changelog-*` convention (the way `git` finds
123+
`git-foo`). Lets plugins be installed as ordinary binaries/npm-bins and discovered by name.
124+
125+
## Dogfooding (the load-bearing requirement)
126+
127+
The built-in changelog renderer is **not** a code path that bypasses the plugin system. It is the
128+
reference implementation behind the same interface the subprocess protocol mirrors:
129+
130+
```
131+
IChangelogGenerator (one interface)
132+
├── BuiltinChangelogGenerator — in-process; today's ChangelogFileWriter logic, refactored to
133+
│ consume the ChangelogRequest model and return the rendered entry
134+
└── SubprocessChangelogGenerator — serializes the same ChangelogRequest to stdin, reads stdout
135+
```
136+
137+
Concretely:
138+
- Refactor `ChangelogFileWriter` so its rendering core takes a **`ChangelogRequest`** (the exact
139+
object serialized to plugin stdin) and returns the rendered entry string — the same value a
140+
subprocess plugin returns on stdout. File I/O / insertion stays in the engine, outside both
141+
implementations.
142+
- The built-in is selected by `"generator": "default"` and runs in-process (no fork — dogfooding means
143+
*same contract/model*, not literally shelling out to ourselves, which would be slow and pointless).
144+
- **Golden parity:** the existing changelog goldens (`E2E/Parity/ChangelogGoldenTests`,
145+
`ChangelogFileWriterTests`) must pass unchanged when routed through `BuiltinChangelogGenerator`.
146+
That test is the proof the request model carries everything a real generator needs — if a golden
147+
can't be reproduced from the `ChangelogRequest` alone, the contract is missing a field, and we find
148+
out *because our own renderer breaks*, not because a third party complains.
149+
150+
This is the whole point: by forcing our default through the contract, the contract can't silently grow
151+
gaps that only external plugins would hit.
152+
153+
## Phasing
154+
155+
1. Define `ChangelogRequest` (`apiVersion: 1`) + refactor `ChangelogFileWriter` rendering core to
156+
consume it → `BuiltinChangelogGenerator : IChangelogGenerator`. Goldens green. **No external
157+
surface yet** — pure internal dogfood.
158+
2. Add `SubprocessChangelogGenerator` + `changelog.generator` config resolution (string forms first).
159+
3. Document the stdin/stdout contract publicly; ship a trivial reference plugin (a ~20-line Node or
160+
shell script that reproduces `keepachangelog`) as both an example and a contract conformance test.
161+
4. (Later, only if needed) `wazero` sandbox for untrusted plugins — same `ChangelogRequest`, different
162+
transport.
163+
164+
## Open questions
165+
166+
- **Formatter interaction:** does plugin stdout get run through the native formatter
167+
(`changelog-formatter-native-design`), or is the plugin responsible for its own formatting? Leaning:
168+
respect the `format` setting and post-process by default, with a per-generator `"format": false`
169+
opt-out for plugins that emit intentional layout.
170+
- **Batch vs per-package:** per-package is simpler and the default; a future `"mode": "batch"` could
171+
pass all packages in one call for generators that want a single aggregated CHANGELOG. Defer until
172+
asked.
173+
- **Whole-file vs entry:** keeping the plugin to "render one entry" preserves the engine's
174+
insertion/idempotency guarantees. A "take the whole file" mode is more powerful but hands
175+
correctness to the plugin — defer.
176+
- **This is the knope gap:** knope has no plugin system at all. Even the minimal subprocess contract is
177+
a cheap, real differentiator (see `knope-evaluation`).

docs/eliminate-node-engine-design.md

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,41 @@ Node survives the engine in exactly three spots, one per workstream below.
4444
`pnpm exec prettier` / `dprint` / `deno` / etc. Default `format:false`, so most .NET repos
4545
get no formatting.
4646

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
47+
**Target:** a hand-rolled, dependency-free formatter, **default-on**, matching the prettier /
48+
dprint / deno trio byte-for-byte. Full rule spec captured in memory
4949
`changelog-formatter-native-design` (prettier 3.8.4 source rules: heading `#`+space; block
5050
spacing via loose/tight `node.spread` detection; `-` markers + 2-space child indent; verbatim
5151
code fences; trailing-whitespace trim at every line break; single trailing newline; table
5252
column alignment).
5353

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**)
54+
**Status: implemented (opt-in).** `src/Changesets/Commands/Version/Helpers/NativeMarkdownFormatter.cs`
55+
+ tests in `tests/Changesets.Tests/Version/NativeMarkdownFormatterTests.cs`. Wired into
56+
`ChangelogFormatter` as `format:"native"` (rewrites each changelog in place, no subprocess).
57+
Remaining before it becomes the default: regenerate the formatted golden set (Node-present step).
58+
59+
**What was built**
60+
1. `NativeMarkdownFormatter` is a small **block parser + renderer** (not a line scanner): it parses
61+
headings, paragraphs, bullet lists (nested), fenced code, blockquotes, and tables, then renders
62+
with prettier's rules. Unknown constructs fall back to verbatim paragraphs, so the failure mode is
63+
"not reformatted", never Markdig-style corruption (Markdig rejected — corrupts pipe tables).
64+
2. **Loose/tight list model — done.** CommonMark looseness is detected per list (a blank between
65+
items, or a blank between an item's block children) and is level-correct under nesting. This is
66+
what makes a single multi-paragraph summary correctly space out *every* bullet in its section,
67+
while a tight dependency sublist stays gap-free. **Key for longer/gnarly changesets.**
68+
3. **Table column-alignment — done.** Width = `max(3, widest cell)`; padding per `:--`/`--:`/`:-:`
69+
alignment; delimiter row rebuilt; East-Asian-wide width approximated. Strict detection (valid
70+
delimiter row required) means a near-table is passed through verbatim, never mangled.
71+
4. Config wiring:
72+
- `format:"native"` → native formatter (**will become the new default** once goldens are regenerated)
6273
- `format:"prettier"|"dprint"|"deno"|...` → keep the existing shell-out escape hatch
6374
- `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.
75+
76+
**Remaining**
77+
- **Flip the default to `native`** — requires the dual golden sets regenerated from pinned
78+
`@changesets@3.0.0-next.5` (Node must be present): keep the `format:false` raw set; add a
79+
`format:<prettier>` formatted set whose `Normalize` must **not** trim (or it won't actually
80+
exercise the formatter). Add a gnarly golden (fence + nested list + blockquote + table) — already
81+
covered by a unit fixture, to be promoted to a golden.
7082

7183
**Independence:** lands first, standalone. Becomes load-bearing once C makes our engine write
7284
JS changelogs too (they must come out matching prettier without shelling out).
@@ -184,7 +196,7 @@ changelogs, so the native formatter must format them for parity).
184196

185197
| Phase | Work | Unblocks | Risk |
186198
|---|---|---|---|
187-
| 1 | **A** native formatter + dual goldens | parity once C lands | Low — spec fully extracted; table alignment deferrable |
199+
| 1 | **A** native formatter (✅ built, incl. loose/tight + table alignment) + flip default w/ dual goldens | parity once C lands | Low — formatter done; default-flip needs Node-present golden regen |
188200
| 2 | **B** pm graph source + `JsPackage` | C | Med — per-pm CLI quirks, JSON shape drift |
189201
| 3 | **C.1** `IPackage` seam + generalize `ModuleChangelog` | C.2–C.5 | Low — planner already agnostic, mechanical |
190202
| 4 | **C.2** range-aware cascade (match `@changesets`) || **High** — real `@changesets` semantics; parity goldens are the oracle |

src/Changesets/Commands/Version/Helpers/ChangelogFormatter.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ private static readonly (string Name, string[] ConfigFiles)[] s_formatters =
2323
]),
2424
];
2525

26+
// The built-in, Node-free formatter. Selected with format:"native" (and, in a later step, the default).
27+
private const string NativeFormatterName = "native";
28+
2629
private readonly IProcessExecutor _processExecutor;
2730
private readonly IAnsiConsole _console;
2831

@@ -46,6 +49,15 @@ public async Task FormatAsync(IReadOnlyCollection<string> changelogFilePaths, st
4649
return;
4750
}
4851

52+
// The built-in native formatter needs no Node, no package manager, and no subprocess: it rewrites each
53+
// changelog in place to match what prettier would produce. This is the path that lets a dotnet/interop
54+
// repo with no Node toolchain still get a formatted changelog.
55+
if (string.Equals(formatter, NativeFormatterName, StringComparison.OrdinalIgnoreCase))
56+
{
57+
await FormatNativelyAsync(changelogFilePaths);
58+
return;
59+
}
60+
4961
int formatterIndex = Array.FindIndex(s_formatters, f => string.Equals(f.Name, formatter, StringComparison.OrdinalIgnoreCase));
5062
if (formatterIndex < 0)
5163
{
@@ -71,6 +83,30 @@ public async Task FormatAsync(IReadOnlyCollection<string> changelogFilePaths, st
7183
}
7284
}
7385

86+
// Rewrites each changelog in place with the native formatter, skipping files that are already formatted so
87+
// the version command does not touch unchanged files. Degrades gracefully on I/O errors, like the
88+
// subprocess path.
89+
private async Task FormatNativelyAsync(IReadOnlyCollection<string> changelogFilePaths)
90+
{
91+
foreach (string file in changelogFilePaths)
92+
{
93+
try
94+
{
95+
string content = await File.ReadAllTextAsync(file);
96+
string formatted = NativeMarkdownFormatter.Format(content);
97+
98+
if (!string.Equals(content, formatted, StringComparison.Ordinal))
99+
{
100+
await File.WriteAllTextAsync(file, formatted);
101+
}
102+
}
103+
catch (IOException)
104+
{
105+
_console.MarkupLine($"[yellow]warn[/] Could not format '{file}'; left as written.");
106+
}
107+
}
108+
}
109+
74110
private static string? ResolveFormatter(string? format, string workingDirectory)
75111
{
76112
if (string.IsNullOrEmpty(format))

0 commit comments

Comments
 (0)