@pyreon/compiler 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,435 @@
1
+ /**
2
+ * Test-environment audit for the `audit_test_environment` MCP tool (T2.5.7).
3
+ *
4
+ * Scans `*.test.ts` / `*.test.tsx` files under the `packages` tree
5
+ * for **mock-vnode patterns** — tests that construct `{ type, props,
6
+ * children }` object literals (or a custom `vnode(...)` helper) in
7
+ * place of going through the real `h()` from `@pyreon/core`. This
8
+ * class of pattern silently drops rocketstyle / compiler / attrs
9
+ * work from the pipeline, letting bugs through that production
10
+ * would hit immediately (see PR #197 silent metadata drop).
11
+ *
12
+ * The scanner does NOT run the tests or parse TypeScript — a fast
13
+ * regex pass is intentional. Accuracy trades for speed: the false-
14
+ * positive rate is low because the `{ type: ..., props: ...,
15
+ * children: ... }` shape is unusual outside of vnode construction.
16
+ *
17
+ * Output classification:
18
+ * HIGH — mock patterns present, no real `h()` calls and no `h`
19
+ * import from `@pyreon/core`. Most at risk: the file has
20
+ * no pathway to exercise the real pipeline.
21
+ * MEDIUM — mock patterns present, some real `h()` usage — but the
22
+ * mock count is still notable, so a parallel real-`h()`
23
+ * test may be missing for specific scenarios.
24
+ * LOW — either no mocks, or mock count is dwarfed by real usage.
25
+ *
26
+ * Companion to the `validate` and `get_anti_patterns` tools: those
27
+ * tell an agent what to write; this one tells an agent which existing
28
+ * tests need strengthening.
29
+ */
30
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
31
+ import { dirname, join, relative, resolve } from 'node:path'
32
+
33
+ export type AuditRisk = 'high' | 'medium' | 'low'
34
+
35
+ export interface TestAuditEntry {
36
+ /** Absolute path to the test file */
37
+ path: string
38
+ /** Path relative to the repo root for readable reporting */
39
+ relPath: string
40
+ /** Count of object-literal `{ type: ..., props: ..., children: ... }` patterns */
41
+ mockVNodeLiteralCount: number
42
+ /** Count of `vnode` / `mockVNode` / `createVNode` helper DEFINITIONS */
43
+ mockHelperCount: number
44
+ /**
45
+ * Count of CALLS to a known mock-helper name. Captures pervasiveness:
46
+ * a file with one helper definition and 50 call-sites has the same
47
+ * `mockHelperCount` (1) as one with zero calls, but very different
48
+ * exposure. This metric surfaces that.
49
+ */
50
+ mockHelperCallCount: number
51
+ /** Count of lines that look like real `h(...)` calls (`h(Tag, props)` / `h(Component, ...)` shape) */
52
+ realHCallCount: number
53
+ /** True if the file imports `h` from `@pyreon/core` */
54
+ importsH: boolean
55
+ /** Risk classification */
56
+ risk: AuditRisk
57
+ }
58
+
59
+ export interface TestAuditResult {
60
+ /** Repo root discovered by walking up for `packages/` */
61
+ root: string | null
62
+ /** Every test file scanned, sorted by risk (high → low) then path */
63
+ entries: TestAuditEntry[]
64
+ /** Total files scanned */
65
+ totalScanned: number
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════════
69
+ // Discovery
70
+ // ═══════════════════════════════════════════════════════════════════════════════
71
+
72
+ function findMonorepoRoot(startDir: string): string | null {
73
+ let dir = resolve(startDir)
74
+ for (let i = 0; i < 30; i++) {
75
+ try {
76
+ if (statSync(join(dir, 'packages')).isDirectory()) return dir
77
+ } catch {
78
+ // fall through to parent walk
79
+ }
80
+ const parent = dirname(dir)
81
+ if (parent === dir) return null
82
+ dir = parent
83
+ }
84
+ return null
85
+ }
86
+
87
+ function walkTestFiles(dir: string, out: string[], depth = 0): void {
88
+ if (depth > 10) return
89
+ let entries: string[]
90
+ try {
91
+ entries = readdirSync(dir)
92
+ } catch {
93
+ return
94
+ }
95
+ for (const name of entries) {
96
+ if (name.startsWith('.')) continue
97
+ if (name === 'node_modules' || name === 'lib' || name === 'dist') continue
98
+ const full = join(dir, name)
99
+ let isDir = false
100
+ try {
101
+ isDir = statSync(full).isDirectory()
102
+ } catch {
103
+ continue
104
+ }
105
+ if (isDir) {
106
+ walkTestFiles(full, out, depth + 1)
107
+ continue
108
+ }
109
+ if (/\.test\.(ts|tsx)$/.test(name)) {
110
+ out.push(full)
111
+ }
112
+ }
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════════════════════════
116
+ // Pattern detection
117
+ // ═══════════════════════════════════════════════════════════════════════════════
118
+
119
+ /**
120
+ * Matches an object literal carrying `type`, `props`, AND `children`
121
+ * keys — the canonical mock-vnode shape. The `s` flag spans newlines
122
+ * because vnode literals often wrap across multiple lines.
123
+ */
124
+ const MOCK_VNODE_LITERAL_PATTERN =
125
+ /\{\s*type\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?props\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?children\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?\}/gs
126
+
127
+ /**
128
+ * Matches a helper definition that produces a mock vnode. Recognises:
129
+ * const vnode = (...) => ({ type, props, children })
130
+ * const mockVNode = ({ type, props, children })
131
+ * function createVNode(type, props, children)
132
+ *
133
+ * Does NOT match bindings that merely STORE a real VNode with a
134
+ * `vnode`-like name, which are common in component tests:
135
+ * const vnode = defaultRender(...) // real render result
136
+ * const vnode = <span>cell content</span> // real JSX expression
137
+ * const vnode = h('div', null, 'x') // real h() call
138
+ *
139
+ * Distinguisher: a mock helper definition either
140
+ * (a) starts an arrow function / function — RHS begins with `(` or the
141
+ * keyword `function`, OR
142
+ * (b) is itself an inline object literal — RHS begins with `{`.
143
+ * `const vnode = <anything else>` is a binding, not a definition.
144
+ */
145
+ const MOCK_HELPER_PATTERN =
146
+ /(?:(?:const|let)\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*=\s*(?:\(|\{|function\b|async\s))|(?:function\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\()/g
147
+
148
+ /**
149
+ * Matches CALLS to a known mock-helper name:
150
+ * vnode('div', props, children)
151
+ * mockVNode(Component, props)
152
+ * createVNode(...)
153
+ *
154
+ * Non-word boundary before the name avoids hits inside other
155
+ * identifiers (`hasVNode`, `myVnodeImpl`). The helper-def pattern
156
+ * above ALSO matches definitions' own `<name>(` arg list, so the
157
+ * caller should subtract definition count from call count to get
158
+ * usage-only density — but for risk classification, the combined
159
+ * signal (any mock-helper activity) is what we want.
160
+ */
161
+ const MOCK_HELPER_CALL_PATTERN =
162
+ /(?:^|[^a-zA-Z0-9_])(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\(/g
163
+
164
+ /**
165
+ * Matches calls to `h(…)` where the first arg is an uppercase
166
+ * identifier (component) or a lowercase string tag — the two real
167
+ * shapes. Avoids matching:
168
+ * hasSomething(...) — h followed by [a-z]
169
+ * ch() — single h as substring of another name
170
+ * hash() — same
171
+ * The `(?:^|\W)` boundary plus `[A-Z'"\s]` arg requirement handles both.
172
+ */
173
+ const REAL_H_CALL_PATTERN = /(?:^|\W)h\s*\(\s*["'A-Z]/g
174
+
175
+ const IMPORT_H_PATTERN = /import\s*(?:type\s*)?\{[^}]*\bh\b[^}]*\}\s*from\s*['"]@pyreon\/core['"]/
176
+
177
+ /**
178
+ * Predicate: does the `{type, props, children}` literal at this
179
+ * position appear as an argument to a type-guard-like call
180
+ * (`isDocNode(...)`, `hasVNode(...)`, `assertVNode(...)`, etc.)?
181
+ *
182
+ * Type guards take any object shape and return boolean — passing a
183
+ * `{type, props, children}` literal there is testing the guard's
184
+ * duck-typing, not building a mock vnode for a rendering pipeline.
185
+ * False-positive coverage for `utils-coverage.test.ts` and similar.
186
+ */
187
+ function isLiteralInsideTypeGuardCall(source: string, literalStart: number): boolean {
188
+ // Scan back ~60 chars from the literal for `(\b(?:is|has|assert|validate|check)[A-Z]\w*\s*\()`.
189
+ // We're looking for a function-call opening paren that directly
190
+ // contains this literal (no closer `)` in between).
191
+ const window = source.slice(Math.max(0, literalStart - 60), literalStart)
192
+ // The nearest `(` before the literal — count unmatched parens.
193
+ let unmatched = 0
194
+ let openAt = -1
195
+ for (let i = window.length - 1; i >= 0; i--) {
196
+ const ch = window[i]
197
+ if (ch === ')') unmatched++
198
+ else if (ch === '(') {
199
+ if (unmatched === 0) {
200
+ openAt = i
201
+ break
202
+ }
203
+ unmatched--
204
+ }
205
+ }
206
+ if (openAt < 0) return false
207
+ // Is the token immediately before `openAt` an is*/has*/assert*/check*/validate* identifier?
208
+ const head = window.slice(0, openAt)
209
+ return /\b(?:is|has|assert|validate|check)[A-Z]\w*\s*$/.test(head)
210
+ }
211
+
212
+ /**
213
+ * Mask the inside of every backtick-delimited template-literal with
214
+ * spaces. Preserves length so positions/lines/columns stay aligned.
215
+ * Used to keep the literal scanner from counting `{type,props,children}`
216
+ * patterns that live inside test FIXTURE strings (the `cli/doctor.test.ts`
217
+ * case — those are fixtures for the audit tool itself, not actual code).
218
+ *
219
+ * Limitations: doesn't parse `${...}` interpolations precisely. If a
220
+ * fixture contains a balanced `${ ... }` with code we'd want scanned,
221
+ * the surrounding template string still masks it. In practice, mock-
222
+ * vnode literals are never interpolation expressions, so this is fine.
223
+ */
224
+ function maskTemplateStrings(source: string): string {
225
+ return source.replace(/`(?:\\.|[^`\\])*`/g, (m) => `\`${' '.repeat(m.length - 2)}\``)
226
+ }
227
+
228
+ function countMatches(source: string, pattern: RegExp): number {
229
+ let count = 0
230
+ pattern.lastIndex = 0
231
+ while (pattern.exec(source) !== null) count++
232
+ pattern.lastIndex = 0
233
+ return count
234
+ }
235
+
236
+ /**
237
+ * Counts `{type, props, children}` literals, skipping those that
238
+ * appear inside a type-guard-looking call OR inside a template-literal
239
+ * (which is fixture text, not code). Dedicated because the existing
240
+ * `countMatches` helper has no context-aware skip.
241
+ */
242
+ function countMockVNodeLiterals(source: string): number {
243
+ // First mask template-literal contents — fixtures inside backticks
244
+ // (e.g. `\`const v = { type, props, children }\`` written via
245
+ // writeFile in audit's own test) shouldn't count. The mask
246
+ // preserves positions, so the type-guard skip logic still works.
247
+ const masked = maskTemplateStrings(source)
248
+ const pattern = MOCK_VNODE_LITERAL_PATTERN
249
+ let count = 0
250
+ pattern.lastIndex = 0
251
+ let m: RegExpExecArray | null
252
+ while (true) {
253
+ m = pattern.exec(masked)
254
+ if (m === null) break
255
+ if (!isLiteralInsideTypeGuardCall(masked, m.index)) count++
256
+ }
257
+ pattern.lastIndex = 0
258
+ return count
259
+ }
260
+
261
+ function classifyRisk(entry: Omit<TestAuditEntry, 'risk'>): AuditRisk {
262
+ const mocks =
263
+ entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount
264
+ if (mocks === 0) return 'low'
265
+ if (!entry.importsH && entry.realHCallCount === 0) return 'high'
266
+ if (entry.realHCallCount >= mocks) return 'low'
267
+ return 'medium'
268
+ }
269
+
270
+ // ═══════════════════════════════════════════════════════════════════════════════
271
+ // Public API
272
+ // ═══════════════════════════════════════════════════════════════════════════════
273
+
274
+ export function auditTestEnvironment(startDir: string): TestAuditResult {
275
+ // Caller-supplied `startDir` (no default) — `runtime-dom` transitively
276
+ // pulls this file via the `@pyreon/compiler` JSX runtime entry, and its
277
+ // tsconfig narrows `process` to `{ env: ... }` only. Calling
278
+ // `process.cwd()` here breaks that typecheck. MCP / CLI both have full
279
+ // node types; let them resolve cwd at the call site.
280
+ const root = findMonorepoRoot(startDir)
281
+ if (!root) return { root: null, entries: [], totalScanned: 0 }
282
+
283
+ const files: string[] = []
284
+ walkTestFiles(join(root, 'packages'), files)
285
+
286
+ const entries: TestAuditEntry[] = []
287
+ for (const path of files) {
288
+ let source: string
289
+ try {
290
+ source = readFileSync(path, 'utf8')
291
+ } catch {
292
+ continue
293
+ }
294
+ // Skip the scanner's own test fixtures so `audit_test_environment`
295
+ // doesn't report itself.
296
+ if (path.includes('test-audit.test.ts') || path.includes('test-audit-fixture')) {
297
+ continue
298
+ }
299
+
300
+ // Mask template-literal contents once, then run every counter
301
+ // against the masked source. Patterns inside backticks are
302
+ // FIXTURE strings (the audit tool's own test fixtures, doctest
303
+ // examples, etc.) — they shouldn't count toward any metric.
304
+ // `countMockVNodeLiterals` already does its own masking and runs
305
+ // on `source` so it can do its own work; we pass `source` to
306
+ // keep that contract intact.
307
+ const masked = maskTemplateStrings(source)
308
+ const mockVNodeLiteralCount = countMockVNodeLiterals(source)
309
+ const mockHelperCount = countMatches(masked, MOCK_HELPER_PATTERN)
310
+ const mockHelperCallCount = countMatches(masked, MOCK_HELPER_CALL_PATTERN)
311
+ const realHCallCount = countMatches(masked, REAL_H_CALL_PATTERN)
312
+ const importsH = IMPORT_H_PATTERN.test(masked)
313
+
314
+ const base = {
315
+ path,
316
+ relPath: relative(root, path),
317
+ mockVNodeLiteralCount,
318
+ mockHelperCount,
319
+ mockHelperCallCount,
320
+ realHCallCount,
321
+ importsH,
322
+ }
323
+ entries.push({ ...base, risk: classifyRisk(base) })
324
+ }
325
+
326
+ const riskRank = { high: 0, medium: 1, low: 2 }
327
+ entries.sort((a, b) => {
328
+ const cmp = riskRank[a.risk] - riskRank[b.risk]
329
+ if (cmp !== 0) return cmp
330
+ return a.relPath.localeCompare(b.relPath)
331
+ })
332
+
333
+ return { root, entries, totalScanned: files.length }
334
+ }
335
+
336
+ // ═══════════════════════════════════════════════════════════════════════════════
337
+ // Formatter
338
+ // ═══════════════════════════════════════════════════════════════════════════════
339
+
340
+ export interface AuditFormatOptions {
341
+ /** Only include entries at or above this risk level. Default 'medium'. */
342
+ minRisk?: AuditRisk | undefined
343
+ /** Maximum entries to show per risk group. Default 20. */
344
+ limit?: number | undefined
345
+ }
346
+
347
+ function riskAtOrAbove(risk: AuditRisk, min: AuditRisk): boolean {
348
+ const rank = { high: 0, medium: 1, low: 2 }
349
+ return rank[risk] <= rank[min]
350
+ }
351
+
352
+ export function formatTestAudit(
353
+ result: TestAuditResult,
354
+ { minRisk = 'medium', limit = 20 }: AuditFormatOptions = {},
355
+ ): string {
356
+ if (!result.root) {
357
+ return (
358
+ 'No monorepo root found. This tool scans `packages/**/*.test.{ts,tsx}` ' +
359
+ 'for mock-vnode patterns. Run the MCP from the Pyreon repo root to ' +
360
+ 'get useful output.'
361
+ )
362
+ }
363
+
364
+ const relevant = result.entries.filter((e) => riskAtOrAbove(e.risk, minRisk))
365
+ const counts = {
366
+ high: result.entries.filter((e) => e.risk === 'high').length,
367
+ medium: result.entries.filter((e) => e.risk === 'medium').length,
368
+ low: result.entries.filter((e) => e.risk === 'low').length,
369
+ }
370
+ const withMocks = result.entries.filter(
371
+ (e) => e.mockVNodeLiteralCount + e.mockHelperCount > 0,
372
+ ).length
373
+
374
+ const parts: string[] = []
375
+ parts.push(`# Test environment audit — ${result.totalScanned} test files scanned`)
376
+ parts.push('')
377
+ parts.push(
378
+ `**Mock-vnode exposure**: ${withMocks} / ${result.totalScanned} files construct \`{ type, props, children }\` literals or a custom \`vnode()\` helper instead of going through the real \`h()\` from \`@pyreon/core\`. This is the bug class that caused PR #197's silent metadata drop — mock-only tests pass while the real pipeline (rocketstyle attrs, compiler transforms, props forwarding) stays unexercised.`,
379
+ )
380
+ parts.push('')
381
+ parts.push(`**Risk counts**: ${counts.high} high · ${counts.medium} medium · ${counts.low} low`)
382
+ parts.push('')
383
+
384
+ if (relevant.length === 0) {
385
+ parts.push(`No files at risk level "${minRisk}" or above. Every test file either avoids mocks entirely or pairs them with real-\`h()\` coverage.`)
386
+ return parts.join('\n')
387
+ }
388
+
389
+ const byRisk = new Map<AuditRisk, TestAuditEntry[]>()
390
+ for (const entry of relevant) {
391
+ if (!byRisk.has(entry.risk)) byRisk.set(entry.risk, [])
392
+ byRisk.get(entry.risk)!.push(entry)
393
+ }
394
+
395
+ for (const [risk, group] of byRisk) {
396
+ const shown = group.slice(0, limit)
397
+ parts.push(`## ${risk.toUpperCase()} — ${group.length} file${group.length === 1 ? '' : 's'}${shown.length < group.length ? ` (showing ${shown.length})` : ''}`)
398
+ parts.push('')
399
+ parts.push(describeRisk(risk))
400
+ parts.push('')
401
+ for (const entry of shown) {
402
+ const mocks =
403
+ entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount
404
+ const breakdown: string[] = []
405
+ if (entry.mockVNodeLiteralCount > 0) breakdown.push(`${entry.mockVNodeLiteralCount} literal${entry.mockVNodeLiteralCount === 1 ? '' : 's'}`)
406
+ if (entry.mockHelperCount > 0) breakdown.push(`${entry.mockHelperCount} helper${entry.mockHelperCount === 1 ? '' : 's'}`)
407
+ if (entry.mockHelperCallCount > 0) breakdown.push(`${entry.mockHelperCallCount} helper call${entry.mockHelperCallCount === 1 ? '' : 's'}`)
408
+ const hSide =
409
+ entry.realHCallCount > 0
410
+ ? `${entry.realHCallCount} real h() call${entry.realHCallCount === 1 ? '' : 's'}`
411
+ : entry.importsH
412
+ ? `imports h but 0 calls found`
413
+ : `no h import`
414
+ parts.push(`- ${entry.relPath} — ${mocks} mock signal${mocks === 1 ? '' : 's'} (${breakdown.join(' + ')}), ${hSide}`)
415
+ }
416
+ parts.push('')
417
+ }
418
+
419
+ parts.push('---')
420
+ parts.push('')
421
+ parts.push(
422
+ 'Fix: for each HIGH file, add at least one test that imports `h` from `@pyreon/core` and renders the actual component through `h(RealComponent, props)`. The mock version can stay for speed — it is the LACK of a real-`h()` parallel that blocks bug surfacing.',
423
+ )
424
+ return parts.join('\n')
425
+ }
426
+
427
+ function describeRisk(risk: AuditRisk): string {
428
+ if (risk === 'high') {
429
+ return 'Mock patterns present, no real `h()` calls, and no `h` import from `@pyreon/core`. The file has no pathway to exercise the real pipeline — bugs like PR #197 would slip through.'
430
+ }
431
+ if (risk === 'medium') {
432
+ return 'Mock patterns present AND some real `h()` usage — but mocks outnumber real calls, so specific scenarios may be mock-only. Spot-check that each contract the tests assert on goes through at least one real-`h()` path.'
433
+ }
434
+ return 'Mocks dwarfed by real usage OR no mocks at all — low risk.'
435
+ }
@@ -0,0 +1,16 @@
1
+ import { transformJSX } from '../jsx'
2
+
3
+ const t = (code: string) => transformJSX(code, 'input.tsx').code
4
+
5
+ describe('T0.2 — chain depth stress test', () => {
6
+ for (const depth of [4, 5, 10, 20, 50]) {
7
+ test(`depth ${depth} chain compiles without crashing`, () => {
8
+ const lines = ['const v0 = props.x']
9
+ for (let i = 1; i <= depth; i++) lines.push(`const v${i} = v${i - 1} + 1`)
10
+ const code = `function Comp(props) { ${lines.join('; ')}; return <div>{v${depth}}</div> }`
11
+ const result = t(code)
12
+ expect(result).toContain('props.x')
13
+ expect(result).toContain('_bind')
14
+ })
15
+ }
16
+ })
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // Drift guard between the static `detectPyreonPatterns` detector codes
6
+ // and the `[detector: <code>]` annotations on `.claude/rules/anti-patterns.md`.
7
+ // Without this test, a new bullet can land without a detector tag, or
8
+ // a detector code can be renamed without updating the doc. Either
9
+ // direction is a silent inconsistency — consumers read the doc and
10
+ // expect the detector to back it up.
11
+ //
12
+ // The test does one thing: every `[detector: CODE]` tag in the doc
13
+ // must reference a PyreonDiagnosticCode (or the literal `N/A` for
14
+ // bullets explicitly declared doc-only), and every
15
+ // PyreonDiagnosticCode must appear at least once in the doc so the
16
+ // tag-documentation loop is closed.
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url))
19
+ const REPO_ROOT = resolve(HERE, '../../../../../')
20
+ const ANTI_PATTERNS_PATH = resolve(REPO_ROOT, '.claude/rules/anti-patterns.md')
21
+
22
+ // Kept in sync with the `PyreonDiagnosticCode` union in
23
+ // `pyreon-intercept.ts`. When adding a new code, ALSO add a bullet
24
+ // (with the `[detector: <code>]` tag) to `anti-patterns.md`.
25
+ const KNOWN_CODES = [
26
+ 'for-missing-by',
27
+ 'for-with-key',
28
+ 'props-destructured',
29
+ 'process-dev-gate',
30
+ 'empty-theme',
31
+ 'raw-add-event-listener',
32
+ 'raw-remove-event-listener',
33
+ 'date-math-random-id',
34
+ 'on-click-undefined',
35
+ ] as const
36
+ type KnownCode = (typeof KNOWN_CODES)[number]
37
+
38
+ function readAntiPatterns(): string {
39
+ return readFileSync(ANTI_PATTERNS_PATH, 'utf8')
40
+ }
41
+
42
+ function extractDetectorTags(doc: string): string[] {
43
+ const re = /\[detector:\s*([a-z-/ ]+?)\]/gi
44
+ const found: string[] = []
45
+ for (const m of doc.matchAll(re)) {
46
+ // Some bullets document multiple codes on one pattern, e.g.
47
+ // `[detector: raw-add-event-listener / raw-remove-event-listener]`.
48
+ const raw = m[1]!
49
+ for (const code of raw.split('/')) {
50
+ const trimmed = code.trim()
51
+ if (trimmed) found.push(trimmed)
52
+ }
53
+ }
54
+ return found
55
+ }
56
+
57
+ describe('anti-patterns.md detector tags vs PyreonDiagnosticCode', () => {
58
+ const doc = readAntiPatterns()
59
+ const tags = extractDetectorTags(doc)
60
+
61
+ it('every [detector: CODE] tag references a known PyreonDiagnosticCode', () => {
62
+ const validCodes = new Set<string>(KNOWN_CODES)
63
+ const unknown = tags.filter((t) => !validCodes.has(t) && t !== 'N/A')
64
+ expect(unknown).toEqual([])
65
+ })
66
+
67
+ it('every PyreonDiagnosticCode appears at least once as a [detector:] tag', () => {
68
+ const tagSet = new Set(tags)
69
+ const missing: KnownCode[] = []
70
+ for (const code of KNOWN_CODES) {
71
+ if (!tagSet.has(code)) missing.push(code)
72
+ }
73
+ // If this fails, add a bullet for the new detector code to
74
+ // `.claude/rules/anti-patterns.md` with the `[detector: <code>]`
75
+ // suffix. The doc is the human-readable catalog; the detector is
76
+ // the static enforcement arm — they have to name each other.
77
+ expect(missing).toEqual([])
78
+ })
79
+
80
+ it('reports at least as many tags as detector codes (multi-code bullets allowed)', () => {
81
+ expect(tags.length).toBeGreaterThanOrEqual(KNOWN_CODES.length)
82
+ })
83
+ })