@pyreon/mcp 0.13.0 → 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 +3 -3
- 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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import {
|
|
4
|
+
compareVersions,
|
|
5
|
+
filterSince,
|
|
6
|
+
findChangelog,
|
|
7
|
+
formatChangelog,
|
|
8
|
+
formatChangelogIndex,
|
|
9
|
+
loadChangelogRegistry,
|
|
10
|
+
parseChangelog,
|
|
11
|
+
suggestChangelogs,
|
|
12
|
+
} from '../changelog'
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
// tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
|
|
16
|
+
const REPO_ROOT = resolve(HERE, '../../../../../')
|
|
17
|
+
|
|
18
|
+
describe('parseChangelog — synthetic inputs', () => {
|
|
19
|
+
it('returns an empty array on an empty file', () => {
|
|
20
|
+
expect(parseChangelog('')).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('ignores prose before the first ## version heading', () => {
|
|
24
|
+
const doc = `# @pyreon/foo\n\nSome intro that should not land as a version.\n\n## 1.0.0\n\n- Added X`
|
|
25
|
+
const entries = parseChangelog(doc)
|
|
26
|
+
expect(entries).toHaveLength(1)
|
|
27
|
+
expect(entries[0]!.version).toBe('1.0.0')
|
|
28
|
+
expect(entries[0]!.changes).toEqual(['Added X'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('parses ceremonial empty-version bumps as { empty: true }', () => {
|
|
32
|
+
const doc = '# @pyreon/foo\n\n## 0.5.2\n\n## 0.5.1\n'
|
|
33
|
+
const entries = parseChangelog(doc)
|
|
34
|
+
expect(entries.map((e) => e.version)).toEqual(['0.5.2', '0.5.1'])
|
|
35
|
+
expect(entries.every((e) => e.empty)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('splits user-facing changes from dependency-update bullets', () => {
|
|
39
|
+
const doc = `
|
|
40
|
+
## 0.13.0
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- Added shiny new thing
|
|
45
|
+
- Updated dependencies [[\`a1b2\`]]:
|
|
46
|
+
- @pyreon/core@0.13.0
|
|
47
|
+
- @pyreon/reactivity@0.13.0
|
|
48
|
+
`
|
|
49
|
+
const [entry] = parseChangelog(doc)
|
|
50
|
+
expect(entry!.changes).toEqual(['Added shiny new thing'])
|
|
51
|
+
expect(entry!.dependencyUpdates).toHaveLength(1)
|
|
52
|
+
expect(entry!.dependencyUpdates[0]).toContain('@pyreon/core@0.13.0')
|
|
53
|
+
expect(entry!.empty).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('joins multi-line bullet continuations into one entry', () => {
|
|
57
|
+
const doc = `
|
|
58
|
+
## 0.13.0
|
|
59
|
+
|
|
60
|
+
### Minor Changes
|
|
61
|
+
|
|
62
|
+
- First line of a long change
|
|
63
|
+
second line indented
|
|
64
|
+
third line
|
|
65
|
+
|
|
66
|
+
- sub-bullet under the first change
|
|
67
|
+
- another sub-bullet
|
|
68
|
+
|
|
69
|
+
- Separate top-level change
|
|
70
|
+
`
|
|
71
|
+
const [entry] = parseChangelog(doc)
|
|
72
|
+
expect(entry!.changes).toHaveLength(2)
|
|
73
|
+
expect(entry!.changes[0]).toContain('First line')
|
|
74
|
+
expect(entry!.changes[0]).toContain('second line')
|
|
75
|
+
expect(entry!.changes[0]).toContain('sub-bullet')
|
|
76
|
+
expect(entry!.changes[1]).toContain('Separate top-level')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('collapses ### Patch/Minor/Major Changes headings — keeps only bullets', () => {
|
|
80
|
+
const doc = `
|
|
81
|
+
## 0.14.0
|
|
82
|
+
|
|
83
|
+
### Major Changes
|
|
84
|
+
|
|
85
|
+
- Big thing
|
|
86
|
+
|
|
87
|
+
### Minor Changes
|
|
88
|
+
|
|
89
|
+
- Small thing
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- Tiny fix
|
|
94
|
+
`
|
|
95
|
+
const [entry] = parseChangelog(doc)
|
|
96
|
+
expect(entry!.changes).toEqual(['Big thing', 'Small thing', 'Tiny fix'])
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('loadChangelogRegistry — real repo', () => {
|
|
101
|
+
const registry = loadChangelogRegistry(REPO_ROOT)
|
|
102
|
+
|
|
103
|
+
it('discovers the monorepo root', () => {
|
|
104
|
+
expect(registry.root).toBe(REPO_ROOT)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('loads at least 40 packages with CHANGELOG.md', () => {
|
|
108
|
+
expect(registry.byName.size).toBeGreaterThanOrEqual(40)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('every loaded package has a @pyreon/ name + non-empty path', () => {
|
|
112
|
+
for (const [name, cl] of registry.byName) {
|
|
113
|
+
expect(name).toMatch(/^@pyreon\//)
|
|
114
|
+
expect(cl.path).toContain('CHANGELOG.md')
|
|
115
|
+
expect(cl.dir).not.toContain('node_modules')
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('finds @pyreon/query and parses its entries', () => {
|
|
120
|
+
const q = registry.byName.get('@pyreon/query')
|
|
121
|
+
expect(q).toBeDefined()
|
|
122
|
+
expect(q!.entries.length).toBeGreaterThan(0)
|
|
123
|
+
const substantive = q!.entries.filter((e) => !e.empty)
|
|
124
|
+
expect(substantive.length).toBeGreaterThan(0)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('findChangelog + suggestChangelogs', () => {
|
|
129
|
+
const registry = loadChangelogRegistry(REPO_ROOT)
|
|
130
|
+
|
|
131
|
+
it('finds by fully-qualified name', () => {
|
|
132
|
+
const q = findChangelog(registry, '@pyreon/query')
|
|
133
|
+
expect(q).toBeDefined()
|
|
134
|
+
expect(q!.packageName).toBe('@pyreon/query')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('finds by short slug (auto-prefixes @pyreon/)', () => {
|
|
138
|
+
const q = findChangelog(registry, 'query')
|
|
139
|
+
expect(q).toBeDefined()
|
|
140
|
+
expect(q!.packageName).toBe('@pyreon/query')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('returns null for an unknown package', () => {
|
|
144
|
+
expect(findChangelog(registry, 'nonexistent')).toBeNull()
|
|
145
|
+
expect(findChangelog(registry, '@pyreon/bogus')).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('suggestChangelogs returns substring matches', () => {
|
|
149
|
+
const s = suggestChangelogs(registry, 'router')
|
|
150
|
+
expect(s).toContain('@pyreon/router')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('suggestChangelogs caps at 5', () => {
|
|
154
|
+
const s = suggestChangelogs(registry, 'p') // matches many
|
|
155
|
+
expect(s.length).toBeLessThanOrEqual(5)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('formatChangelog', () => {
|
|
160
|
+
const registry = loadChangelogRegistry(REPO_ROOT)
|
|
161
|
+
|
|
162
|
+
it('renders a header with shown / total counts', () => {
|
|
163
|
+
const q = findChangelog(registry, 'query')!
|
|
164
|
+
const out = formatChangelog(q)
|
|
165
|
+
expect(out).toMatch(/^# @pyreon\/query — changelog \(\d+\/\d+ shown\)/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('respects the limit option', () => {
|
|
169
|
+
const q = findChangelog(registry, 'query')!
|
|
170
|
+
const limited = formatChangelog(q, { limit: 1 })
|
|
171
|
+
const versionHeadings = limited.split('\n').filter((l) => /^## /.test(l))
|
|
172
|
+
expect(versionHeadings).toHaveLength(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('omits Updated-dependencies bullets by default', () => {
|
|
176
|
+
const q = findChangelog(registry, 'query')!
|
|
177
|
+
const out = formatChangelog(q, { limit: 10 })
|
|
178
|
+
expect(out).not.toContain('Updated dependencies')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('includes Updated-dependencies when the option is true', () => {
|
|
182
|
+
const q = findChangelog(registry, 'query')!
|
|
183
|
+
const out = formatChangelog(q, { limit: 10, includeDependencyUpdates: true })
|
|
184
|
+
expect(out).toContain('Updated dependencies')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns a "ceremonial bumps only" miss message for empty changelogs', () => {
|
|
188
|
+
const synthetic = {
|
|
189
|
+
packageName: '@pyreon/empty',
|
|
190
|
+
path: '/tmp/nonexistent/CHANGELOG.md',
|
|
191
|
+
dir: '/tmp/nonexistent',
|
|
192
|
+
entries: [
|
|
193
|
+
{ version: '0.1.0', changes: [], dependencyUpdates: [], empty: true },
|
|
194
|
+
{ version: '0.0.9', changes: [], dependencyUpdates: [], empty: true },
|
|
195
|
+
],
|
|
196
|
+
}
|
|
197
|
+
const out = formatChangelog(synthetic)
|
|
198
|
+
expect(out).toContain('no substantive changes')
|
|
199
|
+
expect(out).toContain('ceremonial version bump')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('compareVersions', () => {
|
|
204
|
+
it('orders by numeric segments', () => {
|
|
205
|
+
expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0)
|
|
206
|
+
expect(compareVersions('0.13.0', '0.12.15')).toBeGreaterThan(0) // 13 > 12
|
|
207
|
+
expect(compareVersions('0.12.15', '0.13.0')).toBeLessThan(0)
|
|
208
|
+
expect(compareVersions('0.0.9', '0.0.10')).toBeLessThan(0)
|
|
209
|
+
expect(compareVersions('1.0.0', '1.0.0')).toBe(0)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('handles missing segments as 0', () => {
|
|
213
|
+
expect(compareVersions('1.0', '1.0.0')).toBe(0)
|
|
214
|
+
expect(compareVersions('1', '1.0.1')).toBeLessThan(0)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('orders stable above pre-release', () => {
|
|
218
|
+
expect(compareVersions('1.0.0', '1.0.0-alpha.1')).toBeGreaterThan(0)
|
|
219
|
+
expect(compareVersions('1.0.0-alpha.1', '1.0.0')).toBeLessThan(0)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('orders pre-releases lexicographically within the same core', () => {
|
|
223
|
+
expect(compareVersions('1.0.0-alpha.1', '1.0.0-beta.1')).toBeLessThan(0)
|
|
224
|
+
expect(compareVersions('1.0.0-alpha.3', '1.0.0-alpha.2')).toBeGreaterThan(0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('tolerates non-numeric segments as 0', () => {
|
|
228
|
+
// Not expected from changesets but shouldn't crash.
|
|
229
|
+
// "abc" parses to 0, so "abc.1" == "0.1" == [0, 1] and they tie.
|
|
230
|
+
expect(compareVersions('abc.1', '0.1')).toBe(0)
|
|
231
|
+
expect(compareVersions('', '0.0.0')).toBe(0)
|
|
232
|
+
// Numeric still wins over non-numeric.
|
|
233
|
+
expect(compareVersions('1.0.0', 'abc.0.0')).toBeGreaterThan(0)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('filterSince', () => {
|
|
238
|
+
const entries = [
|
|
239
|
+
{ version: '0.13.0', changes: ['X'], dependencyUpdates: [], empty: false },
|
|
240
|
+
{ version: '0.12.15', changes: ['Y'], dependencyUpdates: [], empty: false },
|
|
241
|
+
{ version: '0.12.14', changes: ['Z'], dependencyUpdates: [], empty: false },
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
it('returns entries strictly newer than the floor', () => {
|
|
245
|
+
const after = filterSince(entries, '0.12.15')
|
|
246
|
+
expect(after.map((e) => e.version)).toEqual(['0.13.0'])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('returns all entries when the floor is below every entry', () => {
|
|
250
|
+
const after = filterSince(entries, '0.0.1')
|
|
251
|
+
expect(after).toHaveLength(3)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('returns empty when the floor equals or exceeds every entry', () => {
|
|
255
|
+
expect(filterSince(entries, '0.13.0')).toEqual([])
|
|
256
|
+
expect(filterSince(entries, '1.0.0')).toEqual([])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('preserves file order (newest-first)', () => {
|
|
260
|
+
const after = filterSince(entries, '0.12.13')
|
|
261
|
+
expect(after.map((e) => e.version)).toEqual(['0.13.0', '0.12.15', '0.12.14'])
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('formatChangelog — since option', () => {
|
|
266
|
+
const changelog = {
|
|
267
|
+
packageName: '@pyreon/foo',
|
|
268
|
+
path: '/tmp/CHANGELOG.md',
|
|
269
|
+
dir: '/tmp',
|
|
270
|
+
entries: [
|
|
271
|
+
{ version: '0.13.0', changes: ['Latest change'], dependencyUpdates: [], empty: false },
|
|
272
|
+
{ version: '0.12.15', changes: ['Middle'], dependencyUpdates: [], empty: false },
|
|
273
|
+
{ version: '0.12.14', changes: ['Oldest'], dependencyUpdates: [], empty: false },
|
|
274
|
+
],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it('includes only versions strictly newer than `since`', () => {
|
|
278
|
+
const out = formatChangelog(changelog, { since: '0.12.15', limit: 10 })
|
|
279
|
+
expect(out).toContain('## 0.13.0')
|
|
280
|
+
expect(out).not.toContain('## 0.12.15')
|
|
281
|
+
expect(out).not.toContain('## 0.12.14')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('shows a dedicated "no changes since vX" miss message', () => {
|
|
285
|
+
const out = formatChangelog(changelog, { since: '0.13.0' })
|
|
286
|
+
expect(out).toContain('no changes since v0.13.0')
|
|
287
|
+
expect(out).toContain('known latest substantive version is v0.13.0')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('labels the header with the since floor', () => {
|
|
291
|
+
const out = formatChangelog(changelog, { since: '0.12.15' })
|
|
292
|
+
expect(out).toMatch(/^# @pyreon\/foo — changelog since v0\.12\.15 \(\d+\/\d+ shown\)/)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
describe('formatChangelogIndex', () => {
|
|
297
|
+
const registry = loadChangelogRegistry(REPO_ROOT)
|
|
298
|
+
|
|
299
|
+
it('lists every package with its latest substantive version', () => {
|
|
300
|
+
const out = formatChangelogIndex(registry)
|
|
301
|
+
expect(out).toMatch(/^# Pyreon Changelogs \(\d+ packages\)/)
|
|
302
|
+
expect(out).toContain('**@pyreon/query**')
|
|
303
|
+
expect(out).toContain('**@pyreon/router**')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns a helpful miss message when no packages/ dir', () => {
|
|
307
|
+
const empty = loadChangelogRegistry('/tmp')
|
|
308
|
+
const out = formatChangelogIndex(empty)
|
|
309
|
+
expect(out).toContain('No changelogs found')
|
|
310
|
+
expect(out).toContain('Pyreon monorepo')
|
|
311
|
+
})
|
|
312
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — mcp snapshot', () => {
|
|
9
|
+
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
10
|
+
const line = renderLlmsTxtLine(manifest)
|
|
11
|
+
expect(line.startsWith('- @pyreon/mcp —')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders a llms-full.txt section with the right header', () => {
|
|
15
|
+
const section = renderLlmsFullSection(manifest)
|
|
16
|
+
expect(section.startsWith('## @pyreon/mcp —')).toBe(true)
|
|
17
|
+
expect(section).toContain('```typescript')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders MCP api-reference entries for every api[] item', () => {
|
|
21
|
+
const record = renderApiReferenceEntries(manifest)
|
|
22
|
+
expect(Object.keys(record).sort()).toEqual([
|
|
23
|
+
'mcp/audit_test_environment',
|
|
24
|
+
'mcp/diagnose',
|
|
25
|
+
'mcp/get_anti_patterns',
|
|
26
|
+
'mcp/get_api',
|
|
27
|
+
'mcp/get_browser_smoke_status',
|
|
28
|
+
'mcp/get_changelog',
|
|
29
|
+
'mcp/get_components',
|
|
30
|
+
'mcp/get_pattern',
|
|
31
|
+
'mcp/get_routes',
|
|
32
|
+
'mcp/migrate_react',
|
|
33
|
+
'mcp/validate',
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import ts from 'typescript'
|
|
5
|
+
import { loadPatternRegistry } from '../patterns'
|
|
6
|
+
|
|
7
|
+
// Code-quality test for the patterns corpus. Every ```ts / ```tsx /
|
|
8
|
+
// ```js / ```jsx fenced code block is parsed by the TypeScript
|
|
9
|
+
// compiler and any SYNTAX errors fail the build.
|
|
10
|
+
//
|
|
11
|
+
// What this catches: typos, unterminated strings, unbalanced braces,
|
|
12
|
+
// bad JSX closing tags, missing commas, broken template literals —
|
|
13
|
+
// the kinds of bugs review usually catches but sometimes doesn't.
|
|
14
|
+
//
|
|
15
|
+
// What this does NOT catch: semantic errors (type mismatches, unknown
|
|
16
|
+
// identifiers, import resolution). A pattern snippet intentionally
|
|
17
|
+
// uses `props` or `items` as stand-ins without declaring them — a
|
|
18
|
+
// full type-check would produce false positives on every such block.
|
|
19
|
+
// If that stricter gate is ever wanted, wrap each block in a
|
|
20
|
+
// synthetic `function _()` harness with a compiler host that stubs
|
|
21
|
+
// the unresolved symbols.
|
|
22
|
+
//
|
|
23
|
+
// If you add a block that needs to be excluded (illustrating a
|
|
24
|
+
// syntax ERROR on purpose, say), give its language tag a suffix —
|
|
25
|
+
// e.g. ```tsx-broken — which the test ignores.
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
// tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
|
|
29
|
+
const REPO_ROOT = resolve(HERE, '../../../../../')
|
|
30
|
+
|
|
31
|
+
// Language tags treated as TS/TSX-like (parsed with TSX script kind).
|
|
32
|
+
const TS_LANGS = new Set(['ts', 'tsx', 'typescript'])
|
|
33
|
+
const JS_LANGS = new Set(['js', 'jsx', 'javascript'])
|
|
34
|
+
|
|
35
|
+
interface CodeBlock {
|
|
36
|
+
lang: string
|
|
37
|
+
source: string
|
|
38
|
+
startLine: number // 1-based line in the source markdown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractCodeBlocks(markdown: string): CodeBlock[] {
|
|
42
|
+
const lines = markdown.split('\n')
|
|
43
|
+
const blocks: CodeBlock[] = []
|
|
44
|
+
let inside: { lang: string; startLine: number; buf: string[] } | null = null
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const line = lines[i]!
|
|
48
|
+
const fence = /^```(.*)$/.exec(line)
|
|
49
|
+
if (fence) {
|
|
50
|
+
if (inside === null) {
|
|
51
|
+
// Opening fence; capture the language tag.
|
|
52
|
+
inside = { lang: fence[1]!.trim(), startLine: i + 2, buf: [] }
|
|
53
|
+
} else {
|
|
54
|
+
// Closing fence; flush.
|
|
55
|
+
blocks.push({ lang: inside.lang, source: inside.buf.join('\n'), startLine: inside.startLine })
|
|
56
|
+
inside = null
|
|
57
|
+
}
|
|
58
|
+
} else if (inside !== null) {
|
|
59
|
+
inside.buf.push(line)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return blocks
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isRelevantBlock(block: CodeBlock): boolean {
|
|
66
|
+
return TS_LANGS.has(block.lang) || JS_LANGS.has(block.lang)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scriptKindFor(lang: string): ts.ScriptKind {
|
|
70
|
+
if (lang === 'tsx' || lang === 'typescript') return ts.ScriptKind.TSX
|
|
71
|
+
if (lang === 'ts') return ts.ScriptKind.TSX // treat `ts` blocks as TSX too — patterns often
|
|
72
|
+
// include inline JSX in "ts" blocks by convention
|
|
73
|
+
if (lang === 'jsx') return ts.ScriptKind.JSX
|
|
74
|
+
if (lang === 'js' || lang === 'javascript') return ts.ScriptKind.JSX // same rationale
|
|
75
|
+
return ts.ScriptKind.TSX
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SyntaxFailure {
|
|
79
|
+
pattern: string
|
|
80
|
+
blockStartLine: number
|
|
81
|
+
lang: string
|
|
82
|
+
diagnostic: string
|
|
83
|
+
blockLine: number // line WITHIN the block (1-based)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `SourceFile.parseDiagnostics` is an internal TS field — not on the
|
|
87
|
+
// public .d.ts surface, but available at runtime after `createSourceFile`
|
|
88
|
+
// with setParentNodes=true. Using a structural cast keeps the test
|
|
89
|
+
// syntax-only (no Program / no type resolution) without pulling in a
|
|
90
|
+
// heavier `transpileModule` pipeline.
|
|
91
|
+
interface SourceFileWithParseDiagnostics extends ts.SourceFile {
|
|
92
|
+
readonly parseDiagnostics: readonly ts.Diagnostic[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkBlock(
|
|
96
|
+
patternName: string,
|
|
97
|
+
block: CodeBlock,
|
|
98
|
+
): SyntaxFailure[] {
|
|
99
|
+
const sf = ts.createSourceFile(
|
|
100
|
+
`${patternName}-block.tsx`,
|
|
101
|
+
block.source,
|
|
102
|
+
ts.ScriptTarget.ESNext,
|
|
103
|
+
true,
|
|
104
|
+
scriptKindFor(block.lang),
|
|
105
|
+
) as SourceFileWithParseDiagnostics
|
|
106
|
+
const failures: SyntaxFailure[] = []
|
|
107
|
+
for (const d of sf.parseDiagnostics) {
|
|
108
|
+
const { line } = sf.getLineAndCharacterOfPosition(d.start ?? 0)
|
|
109
|
+
const message =
|
|
110
|
+
typeof d.messageText === 'string'
|
|
111
|
+
? d.messageText
|
|
112
|
+
: ts.flattenDiagnosticMessageText(d.messageText, '\n')
|
|
113
|
+
failures.push({
|
|
114
|
+
pattern: patternName,
|
|
115
|
+
blockStartLine: block.startLine,
|
|
116
|
+
lang: block.lang,
|
|
117
|
+
diagnostic: message,
|
|
118
|
+
blockLine: line + 1,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
return failures
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('patterns content — every code block is syntactically valid', () => {
|
|
125
|
+
const registry = loadPatternRegistry(REPO_ROOT)
|
|
126
|
+
|
|
127
|
+
it.each(registry.patterns.map((p) => [p.name, p.path]))(
|
|
128
|
+
'%s code blocks parse without syntax errors',
|
|
129
|
+
(name, path) => {
|
|
130
|
+
const body = readFileSync(path, 'utf8')
|
|
131
|
+
const blocks = extractCodeBlocks(body).filter(isRelevantBlock)
|
|
132
|
+
expect(blocks.length).toBeGreaterThan(0) // every pattern has at least 1 code example
|
|
133
|
+
|
|
134
|
+
const failures: SyntaxFailure[] = []
|
|
135
|
+
for (const block of blocks) {
|
|
136
|
+
failures.push(...checkBlock(name, block))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (failures.length > 0) {
|
|
140
|
+
// Render a readable failure message pointing at the exact
|
|
141
|
+
// line inside the pattern file so fixing is trivial.
|
|
142
|
+
const lines = failures.map(
|
|
143
|
+
(f) =>
|
|
144
|
+
` ${f.pattern}:${f.blockStartLine + f.blockLine - 1} (${f.lang} block, line ${f.blockLine} of block): ${f.diagnostic}`,
|
|
145
|
+
)
|
|
146
|
+
throw new Error(`Syntax errors in pattern code blocks:\n${lines.join('\n')}`)
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('extractCodeBlocks — parser contract', () => {
|
|
153
|
+
it('extracts every fenced block with its language tag', () => {
|
|
154
|
+
const md = `# Title
|
|
155
|
+
|
|
156
|
+
Prose.
|
|
157
|
+
|
|
158
|
+
\`\`\`ts
|
|
159
|
+
const x: number = 1
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
Some text.
|
|
163
|
+
|
|
164
|
+
\`\`\`tsx
|
|
165
|
+
const X = () => <div />
|
|
166
|
+
\`\`\`
|
|
167
|
+
|
|
168
|
+
\`\`\`bash
|
|
169
|
+
echo hi
|
|
170
|
+
\`\`\`
|
|
171
|
+
`
|
|
172
|
+
const blocks = extractCodeBlocks(md)
|
|
173
|
+
expect(blocks).toHaveLength(3)
|
|
174
|
+
expect(blocks[0]!.lang).toBe('ts')
|
|
175
|
+
expect(blocks[1]!.lang).toBe('tsx')
|
|
176
|
+
expect(blocks[2]!.lang).toBe('bash')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('carries the correct startLine for each block (1-based into source)', () => {
|
|
180
|
+
const md = `line 1
|
|
181
|
+
line 2
|
|
182
|
+
\`\`\`ts
|
|
183
|
+
content line A
|
|
184
|
+
content line B
|
|
185
|
+
\`\`\`
|
|
186
|
+
`
|
|
187
|
+
const blocks = extractCodeBlocks(md)
|
|
188
|
+
expect(blocks).toHaveLength(1)
|
|
189
|
+
// Opening fence on line 3 → content starts at line 4
|
|
190
|
+
expect(blocks[0]!.startLine).toBe(4)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('is idempotent when run twice on the same input', () => {
|
|
194
|
+
const md = '```ts\nconst x = 1\n```\n'
|
|
195
|
+
expect(extractCodeBlocks(md)).toEqual(extractCodeBlocks(md))
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('isRelevantBlock filter', () => {
|
|
200
|
+
it('includes ts, tsx, typescript, js, jsx, javascript', () => {
|
|
201
|
+
for (const lang of ['ts', 'tsx', 'typescript', 'js', 'jsx', 'javascript']) {
|
|
202
|
+
expect(isRelevantBlock({ lang, source: '', startLine: 1 })).toBe(true)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('excludes bash, html, md, and untagged blocks', () => {
|
|
207
|
+
for (const lang of ['bash', 'html', 'md', '', 'sh', 'json']) {
|
|
208
|
+
expect(isRelevantBlock({ lang, source: '', startLine: 1 })).toBe(false)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('excludes explicit -broken suffixes (intentional error demos)', () => {
|
|
213
|
+
expect(isRelevantBlock({ lang: 'tsx-broken', source: '', startLine: 1 })).toBe(false)
|
|
214
|
+
expect(isRelevantBlock({ lang: 'ts-broken', source: '', startLine: 1 })).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
})
|