@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.
- package/README.md +62 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1306 -303
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/anti-patterns.ts +210 -0
- package/src/api-reference.ts +495 -72
- package/src/changelog.ts +433 -0
- package/src/index.ts +279 -33
- package/src/manifest.ts +187 -0
- package/src/patterns.ts +243 -0
- package/src/tests/anti-patterns.test.ts +180 -0
- package/src/tests/changelog-server.test.ts +176 -0
- package/src/tests/changelog.test.ts +312 -0
- package/src/tests/manifest-snapshot.test.ts +36 -0
- package/src/tests/patterns-code.test.ts +216 -0
- package/src/tests/patterns-content.test.ts +147 -0
- package/src/tests/patterns-server.test.ts +160 -0
- package/src/tests/patterns.test.ts +236 -0
- package/src/tests/server-integration.test.ts +155 -0
- package/src/tests/test-audit-server.test.ts +128 -0
- package/src/tests/validate.test.ts +69 -0
package/src/changelog.ts
ADDED
|
@@ -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
|
+
}
|