@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/patterns.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern registry for the `get_pattern` MCP tool (T2.5.3).
|
|
3
|
+
*
|
|
4
|
+
* Each pattern answers a "how do I do X the right way" question with a
|
|
5
|
+
* code example and rationale. The content is the body of the
|
|
6
|
+
* corresponding `docs/patterns/<name>.md` file, discovered at runtime
|
|
7
|
+
* by walking up from `process.cwd()` to the nearest repo that contains
|
|
8
|
+
* `docs/patterns/`.
|
|
9
|
+
*
|
|
10
|
+
* Why a filesystem lookup instead of bundled content: the patterns
|
|
11
|
+
* belong in the VitePress site (they're first-class docs), and having
|
|
12
|
+
* the MCP fetch them live means the AI sees the same text the human
|
|
13
|
+
* would. Bundling copies would drift.
|
|
14
|
+
*
|
|
15
|
+
* Fallback: if no `docs/patterns/` exists in the walk (e.g. the MCP is
|
|
16
|
+
* running in a consumer repo), the tool reports the miss and lists
|
|
17
|
+
* what patterns WOULD be available if running against the Pyreon
|
|
18
|
+
* monorepo. The list itself is seeded from the directory walk, so
|
|
19
|
+
* adding a new pattern file makes it discoverable without code changes.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import { dirname, join, resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export interface PatternFile {
|
|
25
|
+
/** Slug (filename without extension) — the value consumers pass to get_pattern */
|
|
26
|
+
name: string
|
|
27
|
+
/** Absolute path to the source markdown file */
|
|
28
|
+
path: string
|
|
29
|
+
/** Raw markdown body */
|
|
30
|
+
body: string
|
|
31
|
+
/** Title from the frontmatter or first `# ` heading */
|
|
32
|
+
title: string
|
|
33
|
+
/** Optional one-line summary from the frontmatter */
|
|
34
|
+
summary: string | null
|
|
35
|
+
/** Cross-reference slugs from the frontmatter */
|
|
36
|
+
seeAlso: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PatternRegistry {
|
|
40
|
+
/** Root dir (the `docs/patterns/` directory that was found) */
|
|
41
|
+
root: string | null
|
|
42
|
+
/** All patterns, sorted by slug */
|
|
43
|
+
patterns: PatternFile[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
47
|
+
// Directory walk
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
|
|
50
|
+
// Patterns live at `docs/docs/patterns/` — the VitePress content dir
|
|
51
|
+
// so the same file serves both the MCP tool AND the docs website. We
|
|
52
|
+
// also check the top-level `docs/patterns/` layout for forward
|
|
53
|
+
// compatibility, in case a future migration moves them up.
|
|
54
|
+
const PATTERN_PATH_CANDIDATES: ReadonlyArray<ReadonlyArray<string>> = [
|
|
55
|
+
['docs', 'docs', 'patterns'],
|
|
56
|
+
['docs', 'patterns'],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
function findPatternsDir(startDir: string): string | null {
|
|
60
|
+
let dir = resolve(startDir)
|
|
61
|
+
for (let i = 0; i < 30; i++) {
|
|
62
|
+
for (const segments of PATTERN_PATH_CANDIDATES) {
|
|
63
|
+
const candidate = join(dir, ...segments)
|
|
64
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
65
|
+
return candidate
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(dir)
|
|
69
|
+
if (parent === dir) return null
|
|
70
|
+
dir = parent
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// Frontmatter parser (YAML-ish — no external dep)
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Minimal frontmatter parser. Supports:
|
|
81
|
+
* title: string (unquoted or quoted)
|
|
82
|
+
* summary: string
|
|
83
|
+
* seeAlso: [one, two, three] OR seeAlso:\n - one\n - two
|
|
84
|
+
*
|
|
85
|
+
* Anything else is ignored. Full YAML would be overkill here.
|
|
86
|
+
*/
|
|
87
|
+
function parseFrontmatter(source: string): {
|
|
88
|
+
meta: { title?: string; summary?: string; seeAlso?: string[] }
|
|
89
|
+
body: string
|
|
90
|
+
} {
|
|
91
|
+
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(source)
|
|
92
|
+
if (!match) return { meta: {}, body: source }
|
|
93
|
+
const rawMeta = match[1]!
|
|
94
|
+
const body = match[2]!.trim()
|
|
95
|
+
|
|
96
|
+
const meta: { title?: string; summary?: string; seeAlso?: string[] } = {}
|
|
97
|
+
|
|
98
|
+
const lines = rawMeta.split('\n')
|
|
99
|
+
let seeAlsoActive = false
|
|
100
|
+
const seeAlsoItems: string[] = []
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (seeAlsoActive) {
|
|
104
|
+
const bullet = /^\s*-\s*(.+?)\s*$/.exec(line)
|
|
105
|
+
if (bullet) {
|
|
106
|
+
seeAlsoItems.push(bullet[1]!)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
seeAlsoActive = false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line)
|
|
113
|
+
if (!kv) continue
|
|
114
|
+
const key = kv[1]!
|
|
115
|
+
const value = kv[2]!.trim()
|
|
116
|
+
|
|
117
|
+
if (key === 'title') {
|
|
118
|
+
meta.title = value.replace(/^["']|["']$/g, '')
|
|
119
|
+
} else if (key === 'summary') {
|
|
120
|
+
meta.summary = value.replace(/^["']|["']$/g, '')
|
|
121
|
+
} else if (key === 'seeAlso') {
|
|
122
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
123
|
+
meta.seeAlso = value
|
|
124
|
+
.slice(1, -1)
|
|
125
|
+
.split(',')
|
|
126
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
} else if (value === '') {
|
|
129
|
+
seeAlsoActive = true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (seeAlsoActive && seeAlsoItems.length > 0) {
|
|
135
|
+
meta.seeAlso = seeAlsoItems
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { meta, body }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractFirstHeading(body: string): string | null {
|
|
142
|
+
for (const line of body.split('\n')) {
|
|
143
|
+
const h = /^#\s+(.+)$/.exec(line)
|
|
144
|
+
if (h) return h[1]!
|
|
145
|
+
}
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
150
|
+
// Public API
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
152
|
+
|
|
153
|
+
export function loadPatternRegistry(startDir: string = process.cwd()): PatternRegistry {
|
|
154
|
+
const root = findPatternsDir(startDir)
|
|
155
|
+
if (!root) return { root: null, patterns: [] }
|
|
156
|
+
|
|
157
|
+
const patterns: PatternFile[] = []
|
|
158
|
+
const entries = readdirSync(root).sort()
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!entry.endsWith('.md')) continue
|
|
161
|
+
if (entry.startsWith('.') || entry === 'README.md' || entry === 'index.md') continue
|
|
162
|
+
const filePath = join(root, entry)
|
|
163
|
+
let source: string
|
|
164
|
+
try {
|
|
165
|
+
source = readFileSync(filePath, 'utf8')
|
|
166
|
+
} catch {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const { meta, body } = parseFrontmatter(source)
|
|
170
|
+
const name = entry.replace(/\.md$/, '')
|
|
171
|
+
const title = meta.title ?? extractFirstHeading(body) ?? name
|
|
172
|
+
patterns.push({
|
|
173
|
+
name,
|
|
174
|
+
path: filePath,
|
|
175
|
+
body: source,
|
|
176
|
+
title,
|
|
177
|
+
summary: meta.summary ?? null,
|
|
178
|
+
seeAlso: meta.seeAlso ?? [],
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { root, patterns }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format the registry as a short index listing for the "no arg" case.
|
|
187
|
+
* Each entry: ` - slug — title (summary)`.
|
|
188
|
+
*/
|
|
189
|
+
export function formatPatternIndex(registry: PatternRegistry): string {
|
|
190
|
+
if (!registry.root || registry.patterns.length === 0) {
|
|
191
|
+
return (
|
|
192
|
+
'No patterns found. Patterns live at `docs/docs/patterns/<name>.md` ' +
|
|
193
|
+
'(the VitePress content directory) in the Pyreon monorepo. If you ' +
|
|
194
|
+
'are running the MCP in a consumer project, patterns are not ' +
|
|
195
|
+
'available locally — run the MCP in the Pyreon repo to browse them.'
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parts: string[] = [`# Pyreon Patterns (${registry.patterns.length})`, '']
|
|
200
|
+
parts.push(
|
|
201
|
+
'Call `get_pattern({ name: "<slug>" })` for the full body. Each pattern shows the canonical "do it this way" with code + rationale, plus the anti-pattern to avoid.',
|
|
202
|
+
)
|
|
203
|
+
parts.push('')
|
|
204
|
+
for (const p of registry.patterns) {
|
|
205
|
+
const summary = p.summary ? ` — ${p.summary}` : ''
|
|
206
|
+
parts.push(`- **${p.name}** — ${p.title}${summary}`)
|
|
207
|
+
}
|
|
208
|
+
return parts.join('\n')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Format a single pattern's full body for the MCP response. Prepends
|
|
213
|
+
* a breadcrumb and appends a cross-reference footer if `seeAlso` was
|
|
214
|
+
* populated.
|
|
215
|
+
*/
|
|
216
|
+
export function formatPatternBody(pattern: PatternFile): string {
|
|
217
|
+
const parts: string[] = [pattern.body.trimEnd()]
|
|
218
|
+
if (pattern.seeAlso.length > 0) {
|
|
219
|
+
parts.push('')
|
|
220
|
+
parts.push(
|
|
221
|
+
`---\n\n**See also:** ${pattern.seeAlso.map((s) => `\`get_pattern({ name: "${s}" })\``).join(', ')}`,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
return parts.join('\n')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function findPattern(registry: PatternRegistry, name: string): PatternFile | null {
|
|
228
|
+
for (const p of registry.patterns) {
|
|
229
|
+
if (p.name === name) return p
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function suggestPatterns(registry: PatternRegistry, name: string): string[] {
|
|
235
|
+
const needle = name.toLowerCase()
|
|
236
|
+
const matches: string[] = []
|
|
237
|
+
for (const p of registry.patterns) {
|
|
238
|
+
if (p.name.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle)) {
|
|
239
|
+
matches.push(p.name)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return matches.slice(0, 5)
|
|
243
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import {
|
|
5
|
+
ANTI_PATTERN_CATEGORIES,
|
|
6
|
+
type AntiPatternCategory,
|
|
7
|
+
formatAntiPatterns,
|
|
8
|
+
parseAntiPatterns,
|
|
9
|
+
} from '../anti-patterns'
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
// tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
|
|
13
|
+
const REPO_ROOT = resolve(HERE, '../../../../../')
|
|
14
|
+
const ANTI_PATTERNS_PATH = resolve(REPO_ROOT, '.claude/rules/anti-patterns.md')
|
|
15
|
+
|
|
16
|
+
describe('parseAntiPatterns — real repo file', () => {
|
|
17
|
+
const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
|
|
18
|
+
const entries = parseAntiPatterns(doc)
|
|
19
|
+
|
|
20
|
+
it('returns at least one entry per documented category', () => {
|
|
21
|
+
const categoriesFound = new Set(entries.map((e) => e.category))
|
|
22
|
+
for (const cat of ANTI_PATTERN_CATEGORIES) {
|
|
23
|
+
expect(categoriesFound.has(cat)).toBe(true)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns entries with non-empty name + description', () => {
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
expect(entry.name.length).toBeGreaterThan(0)
|
|
30
|
+
expect(entry.description.length).toBeGreaterThan(10)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('parses multi-code detector tags (e.g. "raw-add / raw-remove")', () => {
|
|
35
|
+
const withDual = entries.find((e) => e.detectorCodes.length > 1)
|
|
36
|
+
expect(withDual).toBeDefined()
|
|
37
|
+
// The raw-listener anti-pattern in `lifecycle` / `architecture` docs
|
|
38
|
+
// both codes on one bullet.
|
|
39
|
+
expect(withDual!.detectorCodes).toContain('raw-add-event-listener')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('parses single-code detector tags on known entries', () => {
|
|
43
|
+
const forMissingBy = entries.find((e) =>
|
|
44
|
+
e.detectorCodes.includes('for-missing-by'),
|
|
45
|
+
)
|
|
46
|
+
expect(forMissingBy).toBeDefined()
|
|
47
|
+
expect(forMissingBy!.category).toBe('jsx')
|
|
48
|
+
expect(forMissingBy!.name).toContain('by')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('includes doc-only entries (no detector tag)', () => {
|
|
52
|
+
const docOnly = entries.filter((e) => e.detectorCodes.length === 0)
|
|
53
|
+
expect(docOnly.length).toBeGreaterThan(10)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves file order within each category', () => {
|
|
57
|
+
// The first reactivity bullet is "Bare signal in JSX text"
|
|
58
|
+
const firstReactivity = entries.find((e) => e.category === 'reactivity')
|
|
59
|
+
expect(firstReactivity!.name).toBe('Bare signal in JSX text')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('parseAntiPatterns — synthetic inputs', () => {
|
|
64
|
+
it('ignores bullets outside a known category heading', () => {
|
|
65
|
+
const doc = `# Anti-Patterns
|
|
66
|
+
|
|
67
|
+
Intro prose — never parsed as an anti-pattern.
|
|
68
|
+
|
|
69
|
+
## Unknown Heading Not In Map
|
|
70
|
+
|
|
71
|
+
- **Ignored** \`[detector: for-missing-by]\`: this should not land in output.
|
|
72
|
+
|
|
73
|
+
## Reactivity Mistakes
|
|
74
|
+
|
|
75
|
+
- **Real entry**: this one does land.
|
|
76
|
+
`
|
|
77
|
+
const entries = parseAntiPatterns(doc)
|
|
78
|
+
expect(entries).toHaveLength(1)
|
|
79
|
+
expect(entries[0]!.name).toBe('Real entry')
|
|
80
|
+
expect(entries[0]!.category).toBe('reactivity')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('strips trailing colon and leading whitespace from the description', () => {
|
|
84
|
+
const doc = `## JSX Mistakes
|
|
85
|
+
|
|
86
|
+
- **X** : body text here`
|
|
87
|
+
const [only] = parseAntiPatterns(doc)
|
|
88
|
+
expect(only!.description).toBe('body text here')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('accepts bullets with NO detector tag', () => {
|
|
92
|
+
const doc = `## JSX Mistakes
|
|
93
|
+
|
|
94
|
+
- **Plain**: no tag here`
|
|
95
|
+
const [only] = parseAntiPatterns(doc)
|
|
96
|
+
expect(only!.detectorCodes).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('survives backtick-wrapped detector tags', () => {
|
|
100
|
+
const doc = `## JSX Mistakes
|
|
101
|
+
|
|
102
|
+
- **X** \`[detector: for-missing-by]\`: body`
|
|
103
|
+
const [only] = parseAntiPatterns(doc)
|
|
104
|
+
expect(only!.detectorCodes).toEqual(['for-missing-by'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('splits multi-continuation-line bullets correctly', () => {
|
|
108
|
+
const doc = `## Reactivity Mistakes
|
|
109
|
+
|
|
110
|
+
- **Long one**: first line
|
|
111
|
+
continuation line
|
|
112
|
+
another continuation
|
|
113
|
+
- **Second**: short`
|
|
114
|
+
const entries = parseAntiPatterns(doc)
|
|
115
|
+
expect(entries).toHaveLength(2)
|
|
116
|
+
expect(entries[0]!.name).toBe('Long one')
|
|
117
|
+
expect(entries[0]!.description).toContain('first line')
|
|
118
|
+
expect(entries[0]!.description).toContain('continuation line')
|
|
119
|
+
expect(entries[1]!.name).toBe('Second')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('formatAntiPatterns', () => {
|
|
124
|
+
const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
|
|
125
|
+
const entries = parseAntiPatterns(doc)
|
|
126
|
+
|
|
127
|
+
it('renders an "all" header with entry count + category count', () => {
|
|
128
|
+
const out = formatAntiPatterns(entries, 'all')
|
|
129
|
+
expect(out).toMatch(/^# Pyreon Anti-Patterns \(\d+ total, \d+ categor(y|ies)\)/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('renders a category-filtered header', () => {
|
|
133
|
+
const reactivity = entries.filter((e) => e.category === 'reactivity')
|
|
134
|
+
const out = formatAntiPatterns(reactivity, 'reactivity')
|
|
135
|
+
expect(out).toMatch(/^# Pyreon Anti-Patterns — reactivity \(\d+\)/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('includes detector tags inline on entries that have them', () => {
|
|
139
|
+
const reactivity = entries.filter((e) => e.category === 'reactivity')
|
|
140
|
+
const out = formatAntiPatterns(reactivity, 'reactivity')
|
|
141
|
+
// "Destructuring props" has `[detector: props-destructured]`
|
|
142
|
+
expect(out).toContain('`[detector: props-destructured]`')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns a descriptive message when entries is empty', () => {
|
|
146
|
+
const out = formatAntiPatterns([], 'jsx' as AntiPatternCategory)
|
|
147
|
+
expect(out).toContain('No anti-patterns found in category')
|
|
148
|
+
expect(out).toContain('Valid categories')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('mentions validate + anti-patterns.md in the header prose', () => {
|
|
152
|
+
const out = formatAntiPatterns(entries, 'all')
|
|
153
|
+
expect(out).toContain('anti-patterns.md')
|
|
154
|
+
expect(out).toContain('validate')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('coverage parity — every detector code has a bullet', () => {
|
|
159
|
+
// This test is complementary to the one in the compiler package —
|
|
160
|
+
// it verifies from the MCP side that the parser surfaces every
|
|
161
|
+
// detector code that downstream consumers expect.
|
|
162
|
+
const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
|
|
163
|
+
const entries = parseAntiPatterns(doc)
|
|
164
|
+
const knownCodes = [
|
|
165
|
+
'for-missing-by',
|
|
166
|
+
'for-with-key',
|
|
167
|
+
'props-destructured',
|
|
168
|
+
'process-dev-gate',
|
|
169
|
+
'empty-theme',
|
|
170
|
+
'raw-add-event-listener',
|
|
171
|
+
'raw-remove-event-listener',
|
|
172
|
+
'date-math-random-id',
|
|
173
|
+
'on-click-undefined',
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
it.each(knownCodes)('%s appears on at least one parsed bullet', (code) => {
|
|
177
|
+
const match = entries.find((e) => e.detectorCodes.includes(code))
|
|
178
|
+
expect(match).toBeDefined()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
3
|
+
import { createServer } from '../index'
|
|
4
|
+
|
|
5
|
+
// Real MCP server <-> client round-trip for the T2.5.8 get_changelog
|
|
6
|
+
// tool. Same InMemoryTransport shape as the patterns-server test —
|
|
7
|
+
// exercises tool registration, JSON-RPC framing, and the formatter
|
|
8
|
+
// response shape in one pass.
|
|
9
|
+
|
|
10
|
+
async function newClient(): Promise<{ client: Client; close: () => Promise<void> }> {
|
|
11
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
12
|
+
const server = createServer()
|
|
13
|
+
await server.connect(serverTransport)
|
|
14
|
+
const client = new Client({ name: 'test', version: '0.0.0' })
|
|
15
|
+
await client.connect(clientTransport)
|
|
16
|
+
return {
|
|
17
|
+
client,
|
|
18
|
+
close: async () => {
|
|
19
|
+
await client.close()
|
|
20
|
+
await server.close()
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function callTool(
|
|
26
|
+
client: Client,
|
|
27
|
+
name: string,
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const result = (await client.callTool({ name, arguments: args })) as {
|
|
31
|
+
content: Array<{ type: string; text: string }>
|
|
32
|
+
}
|
|
33
|
+
expect(result.content[0]!.type).toBe('text')
|
|
34
|
+
return result.content[0]!.text
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('MCP server — get_changelog tool', () => {
|
|
38
|
+
it('lists every package when called with no arg', async () => {
|
|
39
|
+
const { client, close } = await newClient()
|
|
40
|
+
try {
|
|
41
|
+
const text = await callTool(client, 'get_changelog', {})
|
|
42
|
+
expect(text).toMatch(/^# Pyreon Changelogs \(\d+ packages\)/)
|
|
43
|
+
expect(text).toContain('**@pyreon/query**')
|
|
44
|
+
expect(text).toContain('**@pyreon/router**')
|
|
45
|
+
expect(text).toContain('**@pyreon/form**')
|
|
46
|
+
} finally {
|
|
47
|
+
await close()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns recent versions for a package (fully-qualified name)', async () => {
|
|
52
|
+
const { client, close } = await newClient()
|
|
53
|
+
try {
|
|
54
|
+
const text = await callTool(client, 'get_changelog', { package: '@pyreon/query' })
|
|
55
|
+
expect(text).toContain('@pyreon/query — changelog')
|
|
56
|
+
// At least one version heading.
|
|
57
|
+
expect(text).toMatch(/^## \d+\.\d+\.\d+/m)
|
|
58
|
+
} finally {
|
|
59
|
+
await close()
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('accepts the short slug (auto-prefixes @pyreon/)', async () => {
|
|
64
|
+
const { client, close } = await newClient()
|
|
65
|
+
try {
|
|
66
|
+
const short = await callTool(client, 'get_changelog', { package: 'query' })
|
|
67
|
+
const qualified = await callTool(client, 'get_changelog', { package: '@pyreon/query' })
|
|
68
|
+
expect(short).toBe(qualified)
|
|
69
|
+
} finally {
|
|
70
|
+
await close()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('respects the limit parameter', async () => {
|
|
75
|
+
const { client, close } = await newClient()
|
|
76
|
+
try {
|
|
77
|
+
const text = await callTool(client, 'get_changelog', { package: 'query', limit: 1 })
|
|
78
|
+
const versionHeadings = text.split('\n').filter((l) => /^## \d+\.\d+/.test(l))
|
|
79
|
+
expect(versionHeadings).toHaveLength(1)
|
|
80
|
+
} finally {
|
|
81
|
+
await close()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('omits Updated-dependencies by default, includes when flag is true', async () => {
|
|
86
|
+
const { client, close } = await newClient()
|
|
87
|
+
try {
|
|
88
|
+
const withoutDeps = await callTool(client, 'get_changelog', {
|
|
89
|
+
package: 'query',
|
|
90
|
+
limit: 10,
|
|
91
|
+
})
|
|
92
|
+
const withDeps = await callTool(client, 'get_changelog', {
|
|
93
|
+
package: 'query',
|
|
94
|
+
limit: 10,
|
|
95
|
+
includeDependencyUpdates: true,
|
|
96
|
+
})
|
|
97
|
+
expect(withoutDeps).not.toContain('Updated dependencies')
|
|
98
|
+
expect(withDeps).toContain('Updated dependencies')
|
|
99
|
+
} finally {
|
|
100
|
+
await close()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns suggestions for a misspelled name', async () => {
|
|
105
|
+
const { client, close } = await newClient()
|
|
106
|
+
try {
|
|
107
|
+
const text = await callTool(client, 'get_changelog', { package: 'quer' })
|
|
108
|
+
expect(text).toContain('not found')
|
|
109
|
+
expect(text).toContain('Did you mean')
|
|
110
|
+
expect(text).toContain('@pyreon/query')
|
|
111
|
+
} finally {
|
|
112
|
+
await close()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns a helpful miss message when no match exists at all', async () => {
|
|
117
|
+
const { client, close } = await newClient()
|
|
118
|
+
try {
|
|
119
|
+
const text = await callTool(client, 'get_changelog', { package: 'totally-unrelated-xxx' })
|
|
120
|
+
expect(text).toContain('not found')
|
|
121
|
+
expect(text).toContain('get_changelog()')
|
|
122
|
+
} finally {
|
|
123
|
+
await close()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('accepts a `since` floor and returns only newer versions', async () => {
|
|
128
|
+
const { client, close } = await newClient()
|
|
129
|
+
try {
|
|
130
|
+
// First get the full index to pick a real floor version.
|
|
131
|
+
const full = await callTool(client, 'get_changelog', { package: 'query', limit: 20 })
|
|
132
|
+
// Extract the 3rd-oldest version heading (needs at least 3 for the test to be meaningful).
|
|
133
|
+
const headings = [...full.matchAll(/^## (\S+)$/gm)].map((m) => m[1]!)
|
|
134
|
+
expect(headings.length).toBeGreaterThanOrEqual(3)
|
|
135
|
+
const floor = headings[headings.length - 2]! // everything newer than the second-oldest
|
|
136
|
+
const filtered = await callTool(client, 'get_changelog', {
|
|
137
|
+
package: 'query',
|
|
138
|
+
limit: 20,
|
|
139
|
+
since: floor,
|
|
140
|
+
})
|
|
141
|
+
expect(filtered).toContain(`since v${floor}`)
|
|
142
|
+
expect(filtered).not.toContain(`## ${floor}\n`) // floor itself excluded (strict)
|
|
143
|
+
} finally {
|
|
144
|
+
await close()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('returns the "no changes since" miss message when the floor is the latest', async () => {
|
|
149
|
+
const { client, close } = await newClient()
|
|
150
|
+
try {
|
|
151
|
+
// Find the real latest substantive version from the package.
|
|
152
|
+
const full = await callTool(client, 'get_changelog', { package: 'query', limit: 1 })
|
|
153
|
+
const latest = /^## (\S+)$/m.exec(full)![1]!
|
|
154
|
+
const result = await callTool(client, 'get_changelog', {
|
|
155
|
+
package: 'query',
|
|
156
|
+
since: latest,
|
|
157
|
+
})
|
|
158
|
+
expect(result).toContain(`no changes since v${latest}`)
|
|
159
|
+
} finally {
|
|
160
|
+
await close()
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('rejects a negative limit via zod', async () => {
|
|
165
|
+
const { client, close } = await newClient()
|
|
166
|
+
try {
|
|
167
|
+
const result = (await client.callTool({
|
|
168
|
+
name: 'get_changelog',
|
|
169
|
+
arguments: { package: 'query', limit: -5 },
|
|
170
|
+
})) as { isError?: boolean; content: Array<{ type: string; text: string }> }
|
|
171
|
+
expect(result.isError).toBe(true)
|
|
172
|
+
} finally {
|
|
173
|
+
await close()
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
})
|