@pyreon/compiler 0.15.0 → 0.16.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,513 @@
1
+ /**
2
+ * Project-wide SSG audit — scans route files for SSG / ISR foot-guns
3
+ * surfaced by the SSG roadmap PRs (L5, A, I). Three detector codes ship
4
+ * today:
5
+ *
6
+ * - **`404-outside-layout-dir`** (PR L5 carve-out): a `_404.tsx` (or
7
+ * `_not-found.tsx`) file NOT co-located with a `_layout.tsx`. PR L5's
8
+ * `findNotFoundFallback` filters to layout records with `children`;
9
+ * a standalone `_404.tsx` outside a layout directory renders via the
10
+ * SSG entry's pre-L5 standalone path (no layout chrome). The audit
11
+ * catches this at the filesystem level so users move their
12
+ * `_404.tsx` into the canonical `_layout` directory.
13
+ *
14
+ * - **`dynamic-route-missing-get-static-paths`** (PR A consequence): a
15
+ * dynamic route file (`[id].tsx`, `[...slug].tsx`) that lacks a
16
+ * `getStaticPaths` export. The SSG plugin silently SKIPS the route
17
+ * during auto-detect — the user thinks `/posts/1` etc. are
18
+ * prerendered but the dist has no `dist/posts/<id>/index.html`. The
19
+ * audit catches this at scan time so users add the enumerator OR
20
+ * declare the route as runtime-only.
21
+ *
22
+ * - **`non-literal-revalidate-export`** (PR I limitation): a route
23
+ * file exports `export const revalidate = TTL` (variable reference)
24
+ * or `export const revalidate = ...` (expression). The literal-
25
+ * capture path in `extractLiteralExport` skips non-literals — the
26
+ * manifest's revalidate entry is omitted, platform-driven ISR is
27
+ * silently unconfigured for that route. The audit catches this so
28
+ * users inline the literal (`export const revalidate = 60`).
29
+ *
30
+ * Real-app coverage:
31
+ * - Per-code synthetic-fixture tests in `tests/ssg-audit.test.ts`
32
+ * (one fixture per finding type, bisect-verified by reverting the
33
+ * detector's match condition)
34
+ * - Doctor wiring at `packages/tools/cli/src/doctor.ts:checkSsg`,
35
+ * CLI flag `pyreon doctor --check-ssg [--json]`
36
+ *
37
+ * Same syntactic-only style as `island-audit.ts` — no type-check pass,
38
+ * no module resolution. False negatives acceptable; false positives
39
+ * must be rare. Every finding ships with file path + line/column +
40
+ * actionable fix suggestion.
41
+ */
42
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
43
+ import { dirname, join, relative, resolve } from 'node:path'
44
+ import ts from 'typescript'
45
+
46
+ export type SsgFindingCode =
47
+ | '404-outside-layout-dir'
48
+ | 'dynamic-route-missing-get-static-paths'
49
+ | 'non-literal-revalidate-export'
50
+
51
+ export interface SsgLocation {
52
+ /** Absolute path */
53
+ path: string
54
+ /** Path relative to the repo root for readable reporting */
55
+ relPath: string
56
+ /** 1-based line number */
57
+ line: number
58
+ /** 1-based column number */
59
+ column: number
60
+ }
61
+
62
+ export interface SsgFinding {
63
+ code: SsgFindingCode
64
+ /** One-paragraph human-readable explanation, including the fix path. */
65
+ message: string
66
+ /** Where the finding surfaces. */
67
+ location: SsgLocation
68
+ /**
69
+ * Companion locations for cross-file findings. Not currently emitted
70
+ * by any detector but kept in the contract so future codes have the
71
+ * shape available without an API change.
72
+ */
73
+ related?: SsgLocation[] | undefined
74
+ }
75
+
76
+ export interface SsgAuditResult {
77
+ root: string | null
78
+ findings: SsgFinding[]
79
+ summary: {
80
+ filesScanned: number
81
+ routesScanned: number
82
+ dynamicRoutes: number
83
+ revalidateExports: number
84
+ findingsByCode: Record<SsgFindingCode, number>
85
+ }
86
+ }
87
+
88
+ // ═══════════════════════════════════════════════════════════════════════════════
89
+ // Discovery
90
+ // ═══════════════════════════════════════════════════════════════════════════════
91
+
92
+ function findMonorepoRoot(startDir: string): string | null {
93
+ let dir = resolve(startDir)
94
+ for (let i = 0; i < 30; i++) {
95
+ try {
96
+ if (statSync(join(dir, 'packages')).isDirectory()) return dir
97
+ } catch {
98
+ // fall through
99
+ }
100
+ const parent = dirname(dir)
101
+ if (parent === dir) return null
102
+ dir = parent
103
+ }
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * Walk a directory looking for files under any `routes/` subdirectory.
109
+ * fs-router treats files under `src/routes/` as routes; we mirror the
110
+ * convention. Skips node_modules / lib / dist / test directories.
111
+ */
112
+ function findRouteFiles(rootDir: string, out: string[], depth = 0): void {
113
+ if (depth > 12) return
114
+ let entries: string[]
115
+ try {
116
+ entries = readdirSync(rootDir)
117
+ } catch {
118
+ return
119
+ }
120
+ for (const name of entries) {
121
+ if (name.startsWith('.')) continue
122
+ if (name === 'node_modules' || name === 'lib' || name === 'dist') continue
123
+ if (name === '__tests__' || name === 'tests') continue
124
+ const full = join(rootDir, name)
125
+ let isDir = false
126
+ try {
127
+ isDir = statSync(full).isDirectory()
128
+ } catch {
129
+ continue
130
+ }
131
+ if (isDir) {
132
+ // If this directory is named `routes`, descend and collect every
133
+ // route file under it. Otherwise recurse into the directory
134
+ // looking for nested `routes/` directories (handles
135
+ // `examples/<app>/src/routes/`).
136
+ if (name === 'routes') {
137
+ walkRoutesDir(full, out)
138
+ } else {
139
+ findRouteFiles(full, out, depth + 1)
140
+ }
141
+ continue
142
+ }
143
+ }
144
+ }
145
+
146
+ function walkRoutesDir(dir: string, out: string[]): void {
147
+ let entries: string[]
148
+ try {
149
+ entries = readdirSync(dir)
150
+ } catch {
151
+ return
152
+ }
153
+ for (const name of entries) {
154
+ if (name.startsWith('.')) continue
155
+ if (name === 'node_modules') continue
156
+ const full = join(dir, name)
157
+ let stat
158
+ try {
159
+ stat = statSync(full)
160
+ } catch {
161
+ continue
162
+ }
163
+ if (stat.isDirectory()) {
164
+ walkRoutesDir(full, out)
165
+ continue
166
+ }
167
+ if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) {
168
+ out.push(full)
169
+ }
170
+ }
171
+ }
172
+
173
+ // ═══════════════════════════════════════════════════════════════════════════════
174
+ // AST parse helpers (shared shape with island-audit.ts)
175
+ // ═══════════════════════════════════════════════════════════════════════════════
176
+
177
+ function parseSourceFile(filePath: string): ts.SourceFile | null {
178
+ let source: string
179
+ try {
180
+ source = readFileSync(filePath, 'utf8')
181
+ } catch {
182
+ return null
183
+ }
184
+ return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true)
185
+ }
186
+
187
+ function locOf(source: ts.SourceFile, node: ts.Node): { line: number; column: number } {
188
+ const pos = source.getLineAndCharacterOfPosition(node.getStart(source))
189
+ return { line: pos.line + 1, column: pos.character + 1 }
190
+ }
191
+
192
+ function makeLocation(
193
+ absPath: string,
194
+ source: ts.SourceFile,
195
+ node: ts.Node,
196
+ rootForRel: string,
197
+ ): SsgLocation {
198
+ const { line, column } = locOf(source, node)
199
+ return {
200
+ path: absPath,
201
+ relPath: relative(rootForRel, absPath),
202
+ line,
203
+ column,
204
+ }
205
+ }
206
+
207
+ // ═══════════════════════════════════════════════════════════════════════════════
208
+ // Detectors
209
+ // ═══════════════════════════════════════════════════════════════════════════════
210
+
211
+ /**
212
+ * 1) `_404.tsx` / `_not-found.tsx` outside a `_layout.tsx` directory.
213
+ *
214
+ * fs-router scans `_404.tsx` / `_not-found.tsx` and attaches the default
215
+ * export as `notFoundComponent` on its parent layout's RouteRecord. PR L5's
216
+ * `findNotFoundFallback` filters to records with `Array.isArray(r.children)
217
+ * && r.children.length > 0` — i.e. layouts only. A standalone `_404.tsx`
218
+ * outside a layout directory:
219
+ * - Becomes attached to a page record (no children)
220
+ * - PR L5's walker skips it
221
+ * - SSG entry falls back to the pre-L5 standalone render (no chrome)
222
+ *
223
+ * The audit catches this at filesystem-walk time, fast and structural.
224
+ */
225
+ function detect404OutsideLayoutDir(
226
+ routeFiles: readonly string[],
227
+ rootForRel: string,
228
+ ): SsgFinding[] {
229
+ const findings: SsgFinding[] = []
230
+ // Build a Set of directories that contain a `_layout.{tsx,ts,jsx,js}` file.
231
+ const layoutDirs = new Set<string>()
232
+ for (const file of routeFiles) {
233
+ const base = file.split('/').pop() ?? ''
234
+ if (/^_layout\.(tsx?|jsx?)$/.test(base)) {
235
+ layoutDirs.add(dirname(file))
236
+ }
237
+ }
238
+ for (const file of routeFiles) {
239
+ const base = file.split('/').pop() ?? ''
240
+ if (!/^_(404|not-found)\.(tsx?|jsx?)$/.test(base)) continue
241
+ const dir = dirname(file)
242
+ if (layoutDirs.has(dir)) continue
243
+ // Synthesize a location at line 1 col 1 — the FILE itself is the
244
+ // finding, not a specific line inside it.
245
+ findings.push({
246
+ code: '404-outside-layout-dir',
247
+ message:
248
+ `${base} is not co-located with a _layout.tsx — without a parent layout, PR L5's ` +
249
+ `findNotFoundFallback won't pick it up at SSG time and the 404 will render WITHOUT ` +
250
+ `layout chrome (nav, footer, providers). Move ${base} into a directory that contains ` +
251
+ `_layout.tsx (the canonical pattern: src/routes/_layout.tsx + src/routes/_404.tsx).`,
252
+ location: {
253
+ path: file,
254
+ relPath: relative(rootForRel, file),
255
+ line: 1,
256
+ column: 1,
257
+ },
258
+ })
259
+ }
260
+ return findings
261
+ }
262
+
263
+ /**
264
+ * 2) Dynamic route file missing `getStaticPaths` export.
265
+ *
266
+ * `[id].tsx`, `[...slug].tsx` — under SSG mode without a `getStaticPaths`,
267
+ * the auto-detect step silently skips the route. User expects
268
+ * `dist/posts/1/index.html` but never gets it.
269
+ *
270
+ * We syntactically scan for `export const getStaticPaths` or
271
+ * `export function getStaticPaths`. Re-exports / async-function form
272
+ * supported. Same literal-extraction shape used in fs-router's scanner.
273
+ */
274
+ function detectDynamicRouteMissingGetStaticPaths(
275
+ routeFiles: readonly string[],
276
+ rootForRel: string,
277
+ ): SsgFinding[] {
278
+ const findings: SsgFinding[] = []
279
+ for (const file of routeFiles) {
280
+ const base = file.split('/').pop() ?? ''
281
+ // Dynamic route iff filename contains `[...]` or `[name]` brackets.
282
+ if (!/\[.+\]/.test(base)) continue
283
+ // Skip layouts / errors / 404s — only PAGE files take getStaticPaths.
284
+ if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue
285
+ // Skip API routes under `routes/api/` (path-based convention).
286
+ // fs-router treats `api/` as the runtime-handler namespace; pages
287
+ // are everything else. Caught originally in M3.B against cpa-pw-blog's
288
+ // `api/echo/[...path].ts`.
289
+ if (/[/\\]routes[/\\]api[/\\]/.test(file)) continue
290
+ const source = parseSourceFile(file)
291
+ if (!source) continue
292
+ let hasGetStaticPaths = false
293
+ let hasDefaultExport = false
294
+ function visit(node: ts.Node): void {
295
+ if (hasGetStaticPaths && hasDefaultExport) return
296
+ if (ts.isVariableStatement(node)) {
297
+ const hasExport = node.modifiers?.some(
298
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
299
+ )
300
+ if (hasExport) {
301
+ for (const decl of node.declarationList.declarations) {
302
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'getStaticPaths') {
303
+ hasGetStaticPaths = true
304
+ }
305
+ }
306
+ }
307
+ }
308
+ if (ts.isFunctionDeclaration(node)) {
309
+ const hasExport = node.modifiers?.some(
310
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
311
+ )
312
+ const isDefault = node.modifiers?.some(
313
+ (m) => m.kind === ts.SyntaxKind.DefaultKeyword,
314
+ )
315
+ if (hasExport && node.name?.text === 'getStaticPaths') {
316
+ hasGetStaticPaths = true
317
+ }
318
+ if (hasExport && isDefault) {
319
+ hasDefaultExport = true
320
+ }
321
+ }
322
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
323
+ // `export default <expr>`
324
+ hasDefaultExport = true
325
+ }
326
+ ts.forEachChild(node, visit)
327
+ }
328
+ visit(source)
329
+ // Files without `export default` are API routes by structure. Skip.
330
+ // Page routes require a default-exported component (fs-router renders
331
+ // `route.component`); files exporting only method handlers
332
+ // (`GET` / `POST` / etc.) without a default are API routes wherever
333
+ // they sit in the tree.
334
+ if (!hasDefaultExport) continue
335
+ if (!hasGetStaticPaths) {
336
+ findings.push({
337
+ code: 'dynamic-route-missing-get-static-paths',
338
+ message:
339
+ `Dynamic route "${base}" has no \`getStaticPaths\` export — under \`mode: 'ssg'\` ` +
340
+ `the auto-detect step SILENTLY SKIPS this route, so the dist won't contain prerendered HTML. ` +
341
+ `Either add \`export const getStaticPaths = () => [{ params: { ... } }, ...]\` enumerating ` +
342
+ `the concrete values, OR declare the route as runtime-only by switching to mode: 'ssr' / 'isr'.`,
343
+ location: {
344
+ path: file,
345
+ relPath: relative(rootForRel, file),
346
+ line: 1,
347
+ column: 1,
348
+ },
349
+ })
350
+ }
351
+ }
352
+ return findings
353
+ }
354
+
355
+ /**
356
+ * 3) `export const revalidate = X` where X is NOT a pure literal.
357
+ *
358
+ * PR I's `extractLiteralExport` skips re-export forms (`const x = 60;
359
+ * export { x as revalidate }`) and non-literal expressions
360
+ * (`export const revalidate = TTL` where TTL is a const elsewhere). The
361
+ * manifest emission skips the entry silently — user thinks ISR is wired
362
+ * but `_pyreon-revalidate.json` is missing the path. The audit catches
363
+ * the syntactic shape and warns.
364
+ *
365
+ * Valid literals: NumericLiteral (`60`), FalseKeyword (`false`).
366
+ * Anything else — Identifier reference, BinaryExpression, CallExpression,
367
+ * TemplateLiteral — flagged.
368
+ */
369
+ function detectNonLiteralRevalidateExport(
370
+ routeFiles: readonly string[],
371
+ rootForRel: string,
372
+ ): SsgFinding[] {
373
+ const findings: SsgFinding[] = []
374
+ for (const file of routeFiles) {
375
+ const parsed = parseSourceFile(file)
376
+ if (!parsed) continue
377
+ const source: ts.SourceFile = parsed
378
+ function visit(node: ts.Node): void {
379
+ if (ts.isVariableStatement(node)) {
380
+ const hasExport = node.modifiers?.some(
381
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
382
+ )
383
+ if (!hasExport) {
384
+ ts.forEachChild(node, visit)
385
+ return
386
+ }
387
+ for (const decl of node.declarationList.declarations) {
388
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== 'revalidate') continue
389
+ const init = decl.initializer
390
+ if (!init) continue
391
+ // Accept NumericLiteral and `false` keyword.
392
+ if (ts.isNumericLiteral(init)) continue
393
+ if (init.kind === ts.SyntaxKind.FalseKeyword) continue
394
+ // Anything else is a non-literal that PR I's extractor skips.
395
+ findings.push({
396
+ code: 'non-literal-revalidate-export',
397
+ message:
398
+ `\`export const revalidate\` must be a NUMERIC LITERAL (e.g. \`60\`, \`3600\`) or ` +
399
+ `\`false\` — non-literal expressions (variable references, math, function calls, ` +
400
+ `template literals) are silently dropped from the build-time ISR manifest (PR I's ` +
401
+ `extractLiteralExport limitation). Inline the value: \`export const revalidate = 60\`.`,
402
+ location: makeLocation(file, source, init, rootForRel),
403
+ })
404
+ }
405
+ }
406
+ ts.forEachChild(node, visit)
407
+ }
408
+ visit(source)
409
+ }
410
+ return findings
411
+ }
412
+
413
+ // ═══════════════════════════════════════════════════════════════════════════════
414
+ // Entry point
415
+ // ═══════════════════════════════════════════════════════════════════════════════
416
+
417
+ export function auditSsg(rootDir: string): SsgAuditResult {
418
+ const root = findMonorepoRoot(rootDir) ?? rootDir
419
+ const routeFiles: string[] = []
420
+ findRouteFiles(rootDir, routeFiles)
421
+
422
+ // Count dynamic routes + revalidate exports for the summary (independent
423
+ // of whether each emitted a finding) — useful signal in the JSON output.
424
+ let dynamicRoutes = 0
425
+ let revalidateExports = 0
426
+ for (const file of routeFiles) {
427
+ const base = file.split('/').pop() ?? ''
428
+ if (/\[.+\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) {
429
+ dynamicRoutes++
430
+ }
431
+ const source = parseSourceFile(file)
432
+ if (!source) continue
433
+ function visit(node: ts.Node): void {
434
+ if (ts.isVariableStatement(node)) {
435
+ const hasExport = node.modifiers?.some(
436
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
437
+ )
438
+ if (hasExport) {
439
+ for (const decl of node.declarationList.declarations) {
440
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'revalidate') {
441
+ revalidateExports++
442
+ }
443
+ }
444
+ }
445
+ }
446
+ ts.forEachChild(node, visit)
447
+ }
448
+ visit(source)
449
+ }
450
+
451
+ const findings: SsgFinding[] = [
452
+ ...detect404OutsideLayoutDir(routeFiles, root),
453
+ ...detectDynamicRouteMissingGetStaticPaths(routeFiles, root),
454
+ ...detectNonLiteralRevalidateExport(routeFiles, root),
455
+ ]
456
+
457
+ const findingsByCode: Record<SsgFindingCode, number> = {
458
+ '404-outside-layout-dir': 0,
459
+ 'dynamic-route-missing-get-static-paths': 0,
460
+ 'non-literal-revalidate-export': 0,
461
+ }
462
+ for (const f of findings) findingsByCode[f.code]++
463
+
464
+ return {
465
+ root,
466
+ findings,
467
+ summary: {
468
+ filesScanned: routeFiles.length,
469
+ routesScanned: routeFiles.length,
470
+ dynamicRoutes,
471
+ revalidateExports,
472
+ findingsByCode,
473
+ },
474
+ }
475
+ }
476
+
477
+ // ═══════════════════════════════════════════════════════════════════════════════
478
+ // Formatter (mirrors formatIslandAudit)
479
+ // ═══════════════════════════════════════════════════════════════════════════════
480
+
481
+ export interface SsgAuditFormatOptions {
482
+ /** Filter findings to a minimum severity. Currently all SSG findings
483
+ * are 'warning'-level; reserved for future severity tiers. */
484
+ minSeverity?: 'warning' | 'error' | undefined
485
+ }
486
+
487
+ export function formatSsgAudit(
488
+ result: SsgAuditResult,
489
+ _options: SsgAuditFormatOptions = {},
490
+ ): string {
491
+ const lines: string[] = []
492
+ lines.push('── SSG audit ─────────────────────────────────────────────────────')
493
+ lines.push('')
494
+ lines.push(
495
+ `Scanned ${result.summary.routesScanned} route file(s), ${result.summary.dynamicRoutes} dynamic route(s), ${result.summary.revalidateExports} revalidate export(s).`,
496
+ )
497
+ lines.push('')
498
+ if (result.findings.length === 0) {
499
+ lines.push('✓ No SSG / ISR issues found.')
500
+ lines.push('')
501
+ return lines.join('\n')
502
+ }
503
+ lines.push(`Found ${result.findings.length} issue(s):`)
504
+ for (const f of result.findings) {
505
+ lines.push('')
506
+ lines.push(` [${f.code}] ${f.location.relPath}:${f.location.line}:${f.location.column}`)
507
+ lines.push(` ${f.message}`)
508
+ }
509
+ lines.push('')
510
+ lines.push('Run `pyreon doctor --check-ssg --json` for machine-readable output.')
511
+ lines.push('')
512
+ return lines.join('\n')
513
+ }
@@ -2,18 +2,22 @@ import { readFileSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
- // Drift guard between the static `detectPyreonPatterns` detector codes
6
- // and the `[detector: <code>]` annotations on `.claude/rules/anti-patterns.md`.
5
+ // Drift guard between Pyreon's static detectors (compiler + lint) and the
6
+ // `[detector: <code>]` annotations on `.claude/rules/anti-patterns.md`.
7
7
  // Without this test, a new bullet can land without a detector tag, or
8
8
  // a detector code can be renamed without updating the doc. Either
9
9
  // direction is a silent inconsistency — consumers read the doc and
10
10
  // expect the detector to back it up.
11
11
  //
12
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.
13
+ // must reference a known detector (compiler PyreonDiagnosticCode OR
14
+ // @pyreon/lint rule ID without the `pyreon/` prefix), and every
15
+ // compiler PyreonDiagnosticCode must appear at least once in the doc
16
+ // so the tag-documentation loop is closed.
17
+ //
18
+ // Lint rules are NOT required to appear in anti-patterns.md (some are
19
+ // stylistic, not anti-pattern shaped). When they DO appear with a
20
+ // `[detector:]` tag, the tag must match the rule ID's local part.
17
21
 
18
22
  const HERE = dirname(fileURLToPath(import.meta.url))
19
23
  const REPO_ROOT = resolve(HERE, '../../../../../')
@@ -22,7 +26,7 @@ const ANTI_PATTERNS_PATH = resolve(REPO_ROOT, '.claude/rules/anti-patterns.md')
22
26
  // Kept in sync with the `PyreonDiagnosticCode` union in
23
27
  // `pyreon-intercept.ts`. When adding a new code, ALSO add a bullet
24
28
  // (with the `[detector: <code>]` tag) to `anti-patterns.md`.
25
- const KNOWN_CODES = [
29
+ const COMPILER_CODES = [
26
30
  'for-missing-by',
27
31
  'for-with-key',
28
32
  'props-destructured',
@@ -35,8 +39,17 @@ const KNOWN_CODES = [
35
39
  'signal-write-as-call',
36
40
  'static-return-null-conditional',
37
41
  'as-unknown-as-vnodechild',
42
+ 'island-never-with-registry-entry',
43
+ ] as const
44
+ type CompilerCode = (typeof COMPILER_CODES)[number]
45
+
46
+ // `@pyreon/lint` rule IDs that may appear as `[detector:]` tags. Listed
47
+ // WITHOUT the `pyreon/` prefix (the tag convention strips it for
48
+ // readability). Add the rule ID here when documenting a new lint rule
49
+ // in anti-patterns.md.
50
+ const LINT_RULE_DETECTORS = [
51
+ 'storage-signal-v-forwarding',
38
52
  ] as const
39
- type KnownCode = (typeof KNOWN_CODES)[number]
40
53
 
41
54
  function readAntiPatterns(): string {
42
55
  return readFileSync(ANTI_PATTERNS_PATH, 'utf8')
@@ -57,20 +70,20 @@ function extractDetectorTags(doc: string): string[] {
57
70
  return found
58
71
  }
59
72
 
60
- describe('anti-patterns.md detector tags vs PyreonDiagnosticCode', () => {
73
+ describe('anti-patterns.md detector tags vs static detectors', () => {
61
74
  const doc = readAntiPatterns()
62
75
  const tags = extractDetectorTags(doc)
63
76
 
64
- it('every [detector: CODE] tag references a known PyreonDiagnosticCode', () => {
65
- const validCodes = new Set<string>(KNOWN_CODES)
77
+ it('every [detector: CODE] tag references a known detector (compiler or lint)', () => {
78
+ const validCodes = new Set<string>([...COMPILER_CODES, ...LINT_RULE_DETECTORS])
66
79
  const unknown = tags.filter((t) => !validCodes.has(t) && t !== 'N/A')
67
80
  expect(unknown).toEqual([])
68
81
  })
69
82
 
70
83
  it('every PyreonDiagnosticCode appears at least once as a [detector:] tag', () => {
71
84
  const tagSet = new Set(tags)
72
- const missing: KnownCode[] = []
73
- for (const code of KNOWN_CODES) {
85
+ const missing: CompilerCode[] = []
86
+ for (const code of COMPILER_CODES) {
74
87
  if (!tagSet.has(code)) missing.push(code)
75
88
  }
76
89
  // If this fails, add a bullet for the new detector code to
@@ -80,7 +93,7 @@ describe('anti-patterns.md detector tags vs PyreonDiagnosticCode', () => {
80
93
  expect(missing).toEqual([])
81
94
  })
82
95
 
83
- it('reports at least as many tags as detector codes (multi-code bullets allowed)', () => {
84
- expect(tags.length).toBeGreaterThanOrEqual(KNOWN_CODES.length)
96
+ it('reports at least as many tags as compiler detector codes (multi-code bullets allowed)', () => {
97
+ expect(tags.length).toBeGreaterThanOrEqual(COMPILER_CODES.length)
85
98
  })
86
99
  })