@pyreon/mcp 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Changelog parser + registry for the `get_changelog` MCP tool (T2.5.8).
3
+ *
4
+ * AI agents often ask "what changed in @pyreon/X recently?" before
5
+ * writing code against the package — a stale mental model is a
6
+ * frequent bug source. `get_changelog` surfaces recent release notes
7
+ * without the agent having to scrape `git log` or read raw markdown.
8
+ *
9
+ * The parser reads `CHANGELOG.md` files populated by changesets. Each
10
+ * changeset-managed package has a predictable shape:
11
+ *
12
+ * # @pyreon/<name>
13
+ *
14
+ * ## <version>
15
+ *
16
+ * ### Minor Changes | Patch Changes | Major Changes
17
+ *
18
+ * - [#PR] [`sha`] Thanks [@author]! - <body>
19
+ *
20
+ * <continuation>
21
+ *
22
+ * - Updated dependencies [[`sha`]]:
23
+ * - @pyreon/core@0.13.0
24
+ *
25
+ * The parser extracts each `## <version>` section, collecting the
26
+ * user-facing body plus the dependency bumps separately. Consumers
27
+ * typically want the N most recent non-empty versions (the tool's
28
+ * default is 5).
29
+ */
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
31
+ import { dirname, join, resolve } from 'node:path'
32
+
33
+ export interface ChangelogEntry {
34
+ /** Version string as it appears in the heading (`0.13.0`, `1.0.0-alpha.3`, etc.) */
35
+ version: string
36
+ /** Extracted bullets from `### Minor/Patch/Major Changes` sections. Each bullet is a single paragraph. */
37
+ changes: string[]
38
+ /** Extracted `- Updated dependencies [...]` bullets — usually noise for AI consumers, kept separately so the tool can hide them */
39
+ dependencyUpdates: string[]
40
+ /** True if `changes` is empty AND `dependencyUpdates` is empty — these are purely ceremonial version bumps */
41
+ empty: boolean
42
+ }
43
+
44
+ export interface PackageChangelog {
45
+ /** Package name, e.g. `@pyreon/query` */
46
+ packageName: string
47
+ /** Path to the CHANGELOG.md file */
48
+ path: string
49
+ /** Package directory (the dir containing the CHANGELOG) */
50
+ dir: string
51
+ /** All entries, newest first (as the file ordering implies) */
52
+ entries: ChangelogEntry[]
53
+ }
54
+
55
+ export interface ChangelogRegistry {
56
+ /** The repo root discovered via directory walk, or null if none found */
57
+ root: string | null
58
+ /** All package changelogs, keyed by package name */
59
+ byName: Map<string, PackageChangelog>
60
+ }
61
+
62
+ // ═══════════════════════════════════════════════════════════════════════════════
63
+ // Discovery
64
+ // ═══════════════════════════════════════════════════════════════════════════════
65
+
66
+ function findMonorepoRoot(startDir: string): string | null {
67
+ let dir = resolve(startDir)
68
+ for (let i = 0; i < 30; i++) {
69
+ if (existsSync(join(dir, 'packages')) && statSync(join(dir, 'packages')).isDirectory()) {
70
+ return dir
71
+ }
72
+ const parent = dirname(dir)
73
+ if (parent === dir) return null
74
+ dir = parent
75
+ }
76
+ return null
77
+ }
78
+
79
+ function walkPackages(
80
+ dir: string,
81
+ out: Array<{ name: string; dir: string; changelogPath: string }>,
82
+ depth = 0,
83
+ ): void {
84
+ if (depth > 4) return
85
+ let entries: string[]
86
+ try {
87
+ entries = readdirSync(dir)
88
+ } catch {
89
+ return
90
+ }
91
+ for (const name of entries) {
92
+ if (name.startsWith('.') || name === 'node_modules') continue
93
+ const full = join(dir, name)
94
+ let isDir = false
95
+ try {
96
+ isDir = statSync(full).isDirectory()
97
+ } catch {
98
+ continue
99
+ }
100
+ if (!isDir) continue
101
+
102
+ const pkgJsonPath = join(full, 'package.json')
103
+ const changelogPath = join(full, 'CHANGELOG.md')
104
+ if (existsSync(pkgJsonPath) && existsSync(changelogPath)) {
105
+ try {
106
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { name?: unknown }
107
+ if (typeof pkg.name === 'string') {
108
+ out.push({ name: pkg.name, dir: full, changelogPath })
109
+ }
110
+ } catch {
111
+ // ignore malformed package.json
112
+ }
113
+ continue // don't recurse into a package directory
114
+ }
115
+ if (existsSync(pkgJsonPath)) continue // package with no changelog — skip & don't recurse
116
+ walkPackages(full, out, depth + 1)
117
+ }
118
+ }
119
+
120
+ // ═══════════════════════════════════════════════════════════════════════════════
121
+ // Parser
122
+ // ═══════════════════════════════════════════════════════════════════════════════
123
+
124
+ /**
125
+ * Parse a CHANGELOG.md body into version entries. The parser assumes
126
+ * the changesets format but tolerates:
127
+ * - empty `## <version>` sections with no body (ceremonial bumps)
128
+ * - mixed `### Patch Changes` / `### Minor Changes` / `### Major Changes` under one version
129
+ * - bullets with multi-line continuations (indented with 2+ spaces)
130
+ * - `- Updated dependencies …` bullets, split off separately
131
+ */
132
+ export function parseChangelog(body: string): ChangelogEntry[] {
133
+ const lines = body.split('\n')
134
+ const entries: ChangelogEntry[] = []
135
+ let currentVersion: string | null = null
136
+ let currentBullets: string[] = []
137
+ let currentDepUpdates: string[] = []
138
+ let currentBuf: string[] = []
139
+ let bufKind: 'change' | 'dep' | null = null
140
+
141
+ const flushBullet = (): void => {
142
+ if (currentBuf.length > 0 && bufKind !== null) {
143
+ const text = currentBuf.join('\n').trim()
144
+ if (text.length > 0) {
145
+ if (bufKind === 'dep') currentDepUpdates.push(text)
146
+ else currentBullets.push(text)
147
+ }
148
+ }
149
+ currentBuf = []
150
+ bufKind = null
151
+ }
152
+
153
+ const flushVersion = (): void => {
154
+ flushBullet()
155
+ if (currentVersion !== null) {
156
+ entries.push({
157
+ version: currentVersion,
158
+ changes: currentBullets,
159
+ dependencyUpdates: currentDepUpdates,
160
+ empty: currentBullets.length === 0 && currentDepUpdates.length === 0,
161
+ })
162
+ }
163
+ currentVersion = null
164
+ currentBullets = []
165
+ currentDepUpdates = []
166
+ }
167
+
168
+ for (const line of lines) {
169
+ const versionMatch = /^## (.+)$/.exec(line)
170
+ if (versionMatch) {
171
+ flushVersion()
172
+ currentVersion = versionMatch[1]!.trim()
173
+ continue
174
+ }
175
+
176
+ if (currentVersion === null) continue // top-of-file prose — ignore
177
+
178
+ // `### Patch Changes` / `### Minor Changes` / `### Major Changes` — ignore
179
+ // the heading itself but keep reading bullets under it.
180
+ if (/^### /.test(line)) {
181
+ flushBullet()
182
+ continue
183
+ }
184
+
185
+ // Start of a new top-level bullet.
186
+ if (/^- /.test(line)) {
187
+ flushBullet()
188
+ currentBuf = [line.replace(/^- /, '')]
189
+ bufKind = /^- Updated dependencies/.test(line) ? 'dep' : 'change'
190
+ continue
191
+ }
192
+
193
+ // Continuation lines — indented bullets or prose.
194
+ if (bufKind !== null && line.length > 0 && /^\s/.test(line)) {
195
+ currentBuf.push(line.replace(/^ {2,4}/, ''))
196
+ continue
197
+ }
198
+
199
+ // Blank line inside a bullet — keep it as a paragraph break.
200
+ if (bufKind !== null && line === '') {
201
+ currentBuf.push('')
202
+ continue
203
+ }
204
+
205
+ // Anything else closes the current bullet.
206
+ flushBullet()
207
+ }
208
+ flushVersion()
209
+ return entries
210
+ }
211
+
212
+ // ═══════════════════════════════════════════════════════════════════════════════
213
+ // Public API
214
+ // ═══════════════════════════════════════════════════════════════════════════════
215
+
216
+ export function loadChangelogRegistry(startDir: string = process.cwd()): ChangelogRegistry {
217
+ const root = findMonorepoRoot(startDir)
218
+ if (!root) return { root: null, byName: new Map() }
219
+
220
+ const found: Array<{ name: string; dir: string; changelogPath: string }> = []
221
+ walkPackages(join(root, 'packages'), found)
222
+
223
+ const byName = new Map<string, PackageChangelog>()
224
+ for (const { name, dir, changelogPath } of found) {
225
+ let source: string
226
+ try {
227
+ source = readFileSync(changelogPath, 'utf8')
228
+ } catch {
229
+ continue
230
+ }
231
+ byName.set(name, {
232
+ packageName: name,
233
+ path: changelogPath,
234
+ dir,
235
+ entries: parseChangelog(source),
236
+ })
237
+ }
238
+ return { root, byName }
239
+ }
240
+
241
+ export function findChangelog(
242
+ registry: ChangelogRegistry,
243
+ packageName: string,
244
+ ): PackageChangelog | null {
245
+ // Accept both `@pyreon/foo` and `foo` forms so the MCP tool can be
246
+ // called with either a fully-qualified name or the short slug.
247
+ if (registry.byName.has(packageName)) {
248
+ return registry.byName.get(packageName)!
249
+ }
250
+ if (!packageName.startsWith('@')) {
251
+ const qualified = `@pyreon/${packageName}`
252
+ if (registry.byName.has(qualified)) return registry.byName.get(qualified)!
253
+ }
254
+ return null
255
+ }
256
+
257
+ export function suggestChangelogs(registry: ChangelogRegistry, needle: string): string[] {
258
+ const lower = needle.toLowerCase()
259
+ const matches: string[] = []
260
+ for (const name of registry.byName.keys()) {
261
+ if (name.toLowerCase().includes(lower)) matches.push(name)
262
+ }
263
+ return matches.slice(0, 5)
264
+ }
265
+
266
+ // ═══════════════════════════════════════════════════════════════════════════════
267
+ // Version comparison
268
+ // ═══════════════════════════════════════════════════════════════════════════════
269
+
270
+ /**
271
+ * Compare two semver-ish version strings. Returns negative if `a < b`,
272
+ * positive if `a > b`, zero if equal. Pre-release suffixes (`-alpha.3`)
273
+ * are compared lexicographically AFTER the numeric portion.
274
+ *
275
+ * Scoped to what changesets actually produce (`0.13.0`, `1.0.0-alpha.3`,
276
+ * `2.5.1`). Not a general-purpose semver implementation — we intentionally
277
+ * do not depend on an external semver package for a 30-line need.
278
+ */
279
+ export function compareVersions(a: string, b: string): number {
280
+ const parse = (v: string): { parts: number[]; pre: string } => {
281
+ const [core, ...preParts] = v.split('-')
282
+ const pre = preParts.join('-')
283
+ const parts = core!.split('.').map((n) => {
284
+ const num = Number.parseInt(n, 10)
285
+ return Number.isNaN(num) ? 0 : num
286
+ })
287
+ return { parts, pre }
288
+ }
289
+ const pa = parse(a)
290
+ const pb = parse(b)
291
+ const maxLen = Math.max(pa.parts.length, pb.parts.length)
292
+ for (let i = 0; i < maxLen; i++) {
293
+ const ai = pa.parts[i] ?? 0
294
+ const bi = pb.parts[i] ?? 0
295
+ if (ai !== bi) return ai - bi
296
+ }
297
+ // Core equal — no-pre beats any pre-release.
298
+ if (pa.pre === '' && pb.pre !== '') return 1
299
+ if (pa.pre !== '' && pb.pre === '') return -1
300
+ if (pa.pre < pb.pre) return -1
301
+ if (pa.pre > pb.pre) return 1
302
+ return 0
303
+ }
304
+
305
+ /**
306
+ * Filter entries to those strictly NEWER than `sinceVersion`. Ceremonial
307
+ * bumps are preserved — the caller decides whether to also filter them
308
+ * via the `empty` flag. Returns all entries in file order (newest first)
309
+ * that satisfy `compareVersions(entry.version, sinceVersion) > 0`.
310
+ */
311
+ export function filterSince(
312
+ entries: ChangelogEntry[],
313
+ sinceVersion: string,
314
+ ): ChangelogEntry[] {
315
+ return entries.filter((e) => compareVersions(e.version, sinceVersion) > 0)
316
+ }
317
+
318
+ // ═══════════════════════════════════════════════════════════════════════════════
319
+ // Formatters
320
+ // ═══════════════════════════════════════════════════════════════════════════════
321
+
322
+ export interface FormatOptions {
323
+ /** How many non-empty versions to include. Default 5. */
324
+ limit?: number | undefined
325
+ /** Show `Updated dependencies` bullets. Default false — AI consumers rarely care. */
326
+ includeDependencyUpdates?: boolean | undefined
327
+ /**
328
+ * Only include versions strictly newer than this one. Accepts any
329
+ * semver-ish string changesets emits (`0.13.0`, `1.0.0-alpha.3`).
330
+ * Omit to show the most recent versions without a floor.
331
+ */
332
+ since?: string | undefined
333
+ }
334
+
335
+ /**
336
+ * Format a package's changelog for the MCP response. Filters empty
337
+ * versions and slices to the `limit` most recent.
338
+ */
339
+ export function formatChangelog(
340
+ changelog: PackageChangelog,
341
+ { limit = 5, includeDependencyUpdates = false, since }: FormatOptions = {},
342
+ ): string {
343
+ const nonEmpty = changelog.entries.filter((e) => !e.empty)
344
+ const afterSince = since ? filterSince(nonEmpty, since) : nonEmpty
345
+ const sliced = afterSince.slice(0, limit)
346
+
347
+ if (sliced.length === 0) {
348
+ if (since) {
349
+ return (
350
+ `# ${changelog.packageName} — no changes since v${since}\n\n` +
351
+ `The package has ${nonEmpty.length} substantive version entr${nonEmpty.length === 1 ? 'y' : 'ies'} in total, but none are newer than v${since}. The known latest substantive version is ` +
352
+ `v${nonEmpty[0]?.version ?? '(none)'}. Drop the \`since\` filter, or pass a lower floor, to see earlier entries.`
353
+ )
354
+ }
355
+ const ceremonial = changelog.entries.length
356
+ const versions = changelog.entries.slice(0, 3).map((e) => e.version).join(', ')
357
+ return (
358
+ `# ${changelog.packageName} — no substantive changes\n\n` +
359
+ `CHANGELOG.md has ${ceremonial} version entr${ceremonial === 1 ? 'y' : 'ies'} ` +
360
+ `(${versions}${ceremonial > 3 ? ', …' : ''}) but every one is a ceremonial ` +
361
+ `version bump with no user-facing body. This usually means the package ` +
362
+ `only received dependency updates during the captured history. Check ` +
363
+ `\`get_changelog({ includeDependencyUpdates: true })\` or the git log ` +
364
+ `if you need the bumps themselves.`
365
+ )
366
+ }
367
+
368
+ const parts: string[] = []
369
+ const sinceSuffix = since ? ` since v${since}` : ''
370
+ parts.push(
371
+ `# ${changelog.packageName} — changelog${sinceSuffix} (${sliced.length}/${afterSince.length} shown)`,
372
+ )
373
+ parts.push('')
374
+ if (afterSince.length > limit) {
375
+ parts.push(
376
+ `Showing the ${limit} most recent substantive versions${sinceSuffix}. ` +
377
+ `${afterSince.length - limit} older versions omitted. Pass \`limit\` to expand.`,
378
+ )
379
+ parts.push('')
380
+ }
381
+
382
+ for (const entry of sliced) {
383
+ parts.push(`## ${entry.version}`)
384
+ parts.push('')
385
+ for (const change of entry.changes) {
386
+ parts.push(`- ${change}`)
387
+ parts.push('')
388
+ }
389
+ if (includeDependencyUpdates && entry.dependencyUpdates.length > 0) {
390
+ parts.push('### Updated dependencies')
391
+ for (const dep of entry.dependencyUpdates) {
392
+ parts.push(`- ${dep}`)
393
+ }
394
+ parts.push('')
395
+ }
396
+ }
397
+
398
+ return parts.join('\n').trimEnd()
399
+ }
400
+
401
+ /**
402
+ * Format the registry as an index when `get_changelog` is called
403
+ * with no package name. Lists every package with its latest
404
+ * substantive version (for orientation).
405
+ */
406
+ export function formatChangelogIndex(registry: ChangelogRegistry): string {
407
+ if (!registry.root || registry.byName.size === 0) {
408
+ return (
409
+ 'No changelogs found. This tool reads CHANGELOG.md files from the ' +
410
+ 'Pyreon monorepo. If you are running the MCP in a consumer project ' +
411
+ 'without the Pyreon packages directory, run the MCP from the Pyreon ' +
412
+ 'repo root to browse releases.'
413
+ )
414
+ }
415
+
416
+ const names = [...registry.byName.keys()].sort()
417
+ const parts: string[] = [`# Pyreon Changelogs (${names.length} packages)`, '']
418
+ parts.push(
419
+ `Call \`get_changelog({ package: "<name>" })\` for per-package release notes. Pass \`limit\` to control how many versions (default 5). The short form \`foo\` maps to \`@pyreon/foo\`.`,
420
+ )
421
+ parts.push('')
422
+
423
+ for (const name of names) {
424
+ const cl = registry.byName.get(name)!
425
+ const latest = cl.entries.find((e) => !e.empty)
426
+ const summary = latest
427
+ ? `latest substantive: v${latest.version}`
428
+ : 'ceremonial bumps only'
429
+ parts.push(`- **${name}** — ${summary}`)
430
+ }
431
+
432
+ return parts.join('\n')
433
+ }