@pyreon/compiler 0.16.0 → 0.19.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.
@@ -14,6 +14,12 @@
14
14
  * the component signature; reading is captured once
15
15
  * and loses reactivity. Access `props.foo` instead
16
16
  * or use `splitProps(props, [...])`.
17
+ * - `props-destructured-body` — `const { foo } = props` written
18
+ * SYNCHRONOUSLY in a component body — the body-scope
19
+ * companion to `props-destructured`. Same capture-
20
+ * once death; nested-function destructures (handler
21
+ * / effect / returned accessor) are NOT flagged
22
+ * (they re-read `props` per invocation).
17
23
  * - `process-dev-gate` — `typeof process !== 'undefined' &&
18
24
  * process.env.NODE_ENV !== 'production'` is dead
19
25
  * code in real Vite browser bundles. Use
@@ -80,6 +86,7 @@ export type PyreonDiagnosticCode =
80
86
  | 'for-missing-by'
81
87
  | 'for-with-key'
82
88
  | 'props-destructured'
89
+ | 'props-destructured-body'
83
90
  | 'process-dev-gate'
84
91
  | 'empty-theme'
85
92
  | 'raw-add-event-listener'
@@ -90,6 +97,7 @@ export type PyreonDiagnosticCode =
90
97
  | 'static-return-null-conditional'
91
98
  | 'as-unknown-as-vnodechild'
92
99
  | 'island-never-with-registry-entry'
100
+ | 'query-options-as-function'
93
101
 
94
102
  export interface PyreonDiagnostic {
95
103
  /** Machine-readable code for filtering + programmatic handling */
@@ -285,6 +293,110 @@ function detectPropsDestructured(
285
293
  )
286
294
  }
287
295
 
296
+ // ═══════════════════════════════════════════════════════════════════════════════
297
+ // Pattern: body-scope `const { x } = props` destructure
298
+ // ═══════════════════════════════════════════════════════════════════════════════
299
+
300
+ /**
301
+ * Strip the wrappers that can sit between `=` and the props identifier
302
+ * (`const { x } = (props as Props)!`) so we can compare the base
303
+ * expression's identity to the component's first-parameter name.
304
+ */
305
+ function unwrapInitializer(expr: ts.Expression): ts.Expression {
306
+ let cur = expr
307
+ let prev: ts.Expression | undefined
308
+ while (cur !== prev) {
309
+ prev = cur
310
+ if (ts.isParenthesizedExpression(cur)) cur = cur.expression
311
+ else if (ts.isAsExpression(cur)) cur = cur.expression
312
+ else if (ts.isSatisfiesExpression(cur)) cur = cur.expression
313
+ else if (ts.isNonNullExpression(cur)) cur = cur.expression
314
+ }
315
+ return cur
316
+ }
317
+
318
+ /**
319
+ * Body-scope companion to {@link detectPropsDestructured}. Flags
320
+ * `const { x } = props` (also `let` / `var`, aliases, defaults, rest,
321
+ * nested patterns) written SYNCHRONOUSLY in a component's body.
322
+ *
323
+ * Why this is the footgun: the compiler emits `<C prop={sig()} />` as a
324
+ * getter-shaped reactive prop. `const { x } = props` fires that getter
325
+ * exactly ONCE at setup — `x` is a dead snapshot, never re-reads when
326
+ * the signal changes. `props.x` (live member access inside a tracking
327
+ * scope) or `splitProps(props, ['x'])` preserve the subscription.
328
+ *
329
+ * Precision (zero false positives is the priority — a missed body-scope
330
+ * destructure is acceptable, a wrong one is not):
331
+ * - Only PascalCase, JSX-rendering functions (`isComponentShapedFunction`
332
+ * + `containsJsx`) — a plain helper that happens to destructure an
333
+ * options bag named `props` is NOT a component and is left alone.
334
+ * - The initializer must be the bare first-parameter identifier
335
+ * (`= props`), unwrapped through paren / `as` / `satisfies` / `!`.
336
+ * `const { x } = props.nested` and `= someOtherObject` are NOT
337
+ * flagged (rarer shapes; out of the canonical scope).
338
+ * - The destructure must be at the component-body top scope. A nested
339
+ * function boundary (`onClick` handler, `effect(() => …)`, a returned
340
+ * reactive accessor) re-reads `props` on each invocation, so those
341
+ * destructures are reactivity-correct — the walk does NOT descend
342
+ * into nested functions.
343
+ * - The first parameter must itself be a plain identifier; the
344
+ * parameter-destructure shape (`({ x }) => …`) is the existing
345
+ * `detectPropsDestructured`'s job, not this one.
346
+ */
347
+ function detectPropsDestructuredBody(
348
+ ctx: DetectContext,
349
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
350
+ ): void {
351
+ if (!isComponentShapedFunction(node)) return
352
+ if (!containsJsx(node)) return
353
+ if (!node.parameters.length) return
354
+ const first = node.parameters[0]
355
+ // First param must be a plain identifier — the destructured-param
356
+ // shape is detectPropsDestructured's domain.
357
+ if (!first || !ts.isIdentifier(first.name)) return
358
+ const paramName = first.name.text
359
+ const body = node.body
360
+ if (!body || !ts.isBlock(body)) return
361
+
362
+ function walk(n: ts.Node): void {
363
+ // Do NOT descend into nested functions: a `const { x } = props`
364
+ // inside a handler / effect / returned accessor re-reads on every
365
+ // invocation and is reactivity-correct.
366
+ if (
367
+ ts.isArrowFunction(n) ||
368
+ ts.isFunctionExpression(n) ||
369
+ ts.isFunctionDeclaration(n) ||
370
+ ts.isMethodDeclaration(n) ||
371
+ ts.isGetAccessorDeclaration(n) ||
372
+ ts.isSetAccessorDeclaration(n)
373
+ ) {
374
+ return
375
+ }
376
+ if (
377
+ ts.isVariableDeclaration(n) &&
378
+ ts.isObjectBindingPattern(n.name) &&
379
+ n.name.elements.length > 0 &&
380
+ n.initializer
381
+ ) {
382
+ const base = unwrapInitializer(n.initializer)
383
+ if (ts.isIdentifier(base) && base.text === paramName) {
384
+ pushDiag(
385
+ ctx,
386
+ n,
387
+ 'props-destructured-body',
388
+ `Destructuring \`${paramName}\` in the component body captures the values ONCE during setup — the compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Read \`${paramName}.x\` directly inside the reactive scope (JSX / effect / computed), or use \`splitProps(${paramName}, ['x', ...])\` to carve out a group while preserving reactivity.`,
389
+ getNodeText(ctx, n),
390
+ `// read ${paramName}.x directly, or: const [local] = splitProps(${paramName}, ['x'])`,
391
+ false,
392
+ )
393
+ }
394
+ }
395
+ ts.forEachChild(n, walk)
396
+ }
397
+ for (const stmt of body.statements) walk(stmt)
398
+ }
399
+
288
400
  // ═══════════════════════════════════════════════════════════════════════════════
289
401
  // Pattern: typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
290
402
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -362,6 +474,48 @@ function detectEmptyTheme(ctx: DetectContext, node: ts.CallExpression): void {
362
474
  )
363
475
  }
364
476
 
477
+ // ═══════════════════════════════════════════════════════════════════════════════
478
+ // Pattern: @pyreon/query hook options passed as an object literal
479
+ // ═══════════════════════════════════════════════════════════════════════════════
480
+
481
+ // `useQuery` / `useInfiniteQuery` / `useQueries` / `useSuspenseQuery` take
482
+ // options as a FUNCTION so `queryKey` (etc.) can read Pyreon signals —
483
+ // changing a tracked signal re-runs the options and refetches. An object
484
+ // LITERAL is evaluated once at call time, so the query never reacts to
485
+ // signal changes. `useMutation` is deliberately NOT flagged: its options
486
+ // are a plain object (mutations are imperative, no tracking).
487
+ const QUERY_OPTS_HOOKS = new Set([
488
+ 'useQuery',
489
+ 'useInfiniteQuery',
490
+ 'useQueries',
491
+ 'useSuspenseQuery',
492
+ ])
493
+
494
+ function detectQueryOptionsAsFunction(
495
+ ctx: DetectContext,
496
+ node: ts.CallExpression,
497
+ ): void {
498
+ if (!ts.isIdentifier(node.expression)) return
499
+ const hook = node.expression.text
500
+ if (!QUERY_OPTS_HOOKS.has(hook)) return
501
+ const arg0 = node.arguments[0]
502
+ // Only the unambiguous object-literal-first-arg shape. An identifier /
503
+ // call / function arg can't be statically proven wrong — stay silent.
504
+ if (!arg0 || !ts.isObjectLiteralExpression(arg0)) return
505
+
506
+ const objText = getNodeText(ctx, arg0)
507
+ pushDiag(
508
+ ctx,
509
+ node,
510
+ 'query-options-as-function',
511
+ `\`${hook}\` takes options as a FUNCTION so \`queryKey\` can read signals and refetch reactively — an object literal is captured once and never reacts. Wrap it: \`${hook}(() => (...))\`.`,
512
+ getNodeText(ctx, node),
513
+ `${hook}(() => (${objText}))`,
514
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
515
+ false,
516
+ )
517
+ }
518
+
365
519
  // ═══════════════════════════════════════════════════════════════════════════════
366
520
  // Pattern: raw addEventListener / removeEventListener
367
521
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -780,6 +934,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
780
934
  ts.isFunctionExpression(node)
781
935
  ) {
782
936
  detectPropsDestructured(ctx, node)
937
+ detectPropsDestructuredBody(ctx, node)
783
938
  detectStaticReturnNullConditional(ctx, node)
784
939
  }
785
940
  if (ts.isBinaryExpression(node)) {
@@ -794,6 +949,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
794
949
  detectRawEventListener(ctx, node)
795
950
  detectSignalWriteAsCall(ctx, node)
796
951
  detectIslandNeverWithRegistry(ctx, node)
952
+ detectQueryOptionsAsFunction(ctx, node)
797
953
  }
798
954
  if (ts.isJsxAttribute(node)) {
799
955
  detectOnClickUndefined(ctx, node)
@@ -839,12 +995,20 @@ export function hasPyreonPatterns(code: string): boolean {
839
995
  (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
840
996
  /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
841
997
  /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
998
+ // props-destructured-body: `const { … } = <ident>` anywhere. Loose
999
+ // on purpose — the AST walker is the precise gate; this only has to
1000
+ // avoid skipping the full walk.
1001
+ /\b(?:const|let|var)\s+\{[^}]*\}\s*=\s*[A-Za-z_$]/.test(code) ||
842
1002
  // signal-write-as-call: `const X = signal(` declaration anywhere
843
1003
  /\b(?:signal|computed)\s*[<(]/.test(code) ||
844
1004
  // static-return-null-conditional: `if (...) return null` anywhere
845
1005
  /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
846
1006
  // as-unknown-as-vnodechild
847
1007
  /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
1008
+ // query-options-as-function: a query hook called with an object literal
1009
+ /\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(
1010
+ code,
1011
+ ) ||
848
1012
  // island-never-with-registry-entry: a never-strategy declaration AND a
849
1013
  // hydrateIslands call must both appear in the same source for the bug
850
1014
  // shape to trigger. Pre-filter on EITHER half — the AST walker fast-
@@ -181,6 +181,55 @@ interface DetectContext {
181
181
  code: string
182
182
  diagnostics: ReactDiagnostic[]
183
183
  reactImportedHooks: Set<string>
184
+ /**
185
+ * Identifiers bound to a signal factory (`const x = signal(...)` /
186
+ * `computed(...)` / `useSignal(...)` / `createSignal(...)`) anywhere in the
187
+ * file. Only `const` declarations are tracked — `let`/`var` may be
188
+ * reassigned to a non-signal value, so a `.value` write through them
189
+ * wouldn't be a reliable signal-write. The collection is scope-blind for
190
+ * the same reason `collectSignalBindings` in `pyreon-intercept.ts` is — the
191
+ * rare shadow-a-signal-name case is acceptable noise; the precision win is
192
+ * eliminating the `input.value = ''` / `cell.value = x` / `o.value = y`
193
+ * false-positive class entirely.
194
+ */
195
+ signalBindings: Set<string>
196
+ }
197
+
198
+ /**
199
+ * Collects every identifier bound to a signal factory call. Mirrors
200
+ * `pyreon-intercept.ts:collectSignalBindings` but also recognises the
201
+ * `useSignal` / `createSignal` aliases (Solid / hook-style) so the React
202
+ * detector — which runs on cross-framework migration input — doesn't miss a
203
+ * genuine `mySignal.value = x` written by someone coming from Solid/Vue.
204
+ */
205
+ function collectDetectSignalBindings(sf: ts.SourceFile): Set<string> {
206
+ const names = new Set<string>()
207
+ function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
208
+ if (!init || !ts.isCallExpression(init)) return false
209
+ const callee = init.expression
210
+ if (!ts.isIdentifier(callee)) return false
211
+ return (
212
+ callee.text === 'signal' ||
213
+ callee.text === 'computed' ||
214
+ callee.text === 'useSignal' ||
215
+ callee.text === 'createSignal'
216
+ )
217
+ }
218
+ function walk(node: ts.Node): void {
219
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
220
+ const list = node.parent
221
+ if (
222
+ ts.isVariableDeclarationList(list) &&
223
+ (list.flags & ts.NodeFlags.Const) !== 0 &&
224
+ isSignalFactoryCall(node.initializer)
225
+ ) {
226
+ names.add(node.name.text)
227
+ }
228
+ }
229
+ ts.forEachChild(node, walk)
230
+ }
231
+ walk(sf)
232
+ return names
184
233
  }
185
234
 
186
235
  function detectGetNodeText(ctx: DetectContext, node: ts.Node): string {
@@ -497,6 +546,15 @@ function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
497
546
 
498
547
  function detectDotValueSignal(ctx: DetectContext, node: ts.PropertyAccessExpression): void {
499
548
  const varName = (node.expression as ts.Identifier).text
549
+ // Precision gate: only flag `X.value = …` when X is actually a tracked
550
+ // signal binding. Without this, the detector false-positived on every
551
+ // DOM-element / data-object `.value` write — `input.value = ''`,
552
+ // `cell.value = x`, `o.value = y`, `ref.current.value = z` (the receiver
553
+ // there is the `.current` PropertyAccess, already excluded by
554
+ // `isDotValueAccess` requiring an Identifier receiver). Require positive
555
+ // evidence the receiver is a `const X = signal(...)` / `computed(...)` /
556
+ // `useSignal(...)` / `createSignal(...)` binding before emitting.
557
+ if (!ctx.signalBindings.has(varName)) return
500
558
  const parent = node.parent
501
559
  if (ts.isBinaryExpression(parent) && parent.left === node) {
502
560
  detectDiag(
@@ -598,6 +656,7 @@ export function detectReactPatterns(code: string, filename = 'input.tsx'): React
598
656
  code,
599
657
  diagnostics: [],
600
658
  reactImportedHooks: new Set<string>(),
659
+ signalBindings: collectDetectSignalBindings(sf),
601
660
  }
602
661
 
603
662
  detectVisit(ctx, sf)
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Reactivity Lens — surface the compiler's already-computed reactivity
3
+ * analysis back to the author at the source.
4
+ *
5
+ * Pyreon's #1 silent footgun class: whether code is reactive is invisible at
6
+ * the moment you write it. `const {x}=props` compiles fine, types fine,
7
+ * renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
8
+ * once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
9
+ * for codegen) and then throws the analysis away. This module pipes it back.
10
+ *
11
+ * `analyzeReactivity()` is the single entry point. It returns a sorted list of
12
+ * {@link ReactivityFinding}s built from TWO faithful sources, neither of which
13
+ * is a fresh approximation:
14
+ *
15
+ * 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
16
+ * span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
17
+ * hoist/static-text). The positive "this is live" claim is the codegen
18
+ * branch itself, so it is correct by construction (drift-gated).
19
+ * 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
20
+ * detectors (`props-destructured`, `signal-write-as-call`, …). Already
21
+ * shipped, already AST-based; the lens just unifies them under one
22
+ * editor-facing taxonomy.
23
+ *
24
+ * Absence of a finding is "not asserted", NEVER an implicit static claim —
25
+ * see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
26
+ *
27
+ * JS-backend only (Phase 1). The native Rust binary emits byte-identical
28
+ * codegen (527 cross-backend equivalence tests), so the JS path is a sound
29
+ * oracle for the analysis; Rust-path parity is Phase 3.
30
+ *
31
+ * @module
32
+ */
33
+
34
+ import { transformJSX_JS } from './jsx'
35
+ import type { ReactivityKind, ReactivitySpan } from './jsx'
36
+ import { detectPyreonPatterns } from './pyreon-intercept'
37
+ import type { PyreonDiagnosticCode } from './pyreon-intercept'
38
+
39
+ export type { ReactivityKind, ReactivitySpan } from './jsx'
40
+
41
+ /** A footgun finding adds `'footgun'` to the structural codegen kinds. */
42
+ export type ReactivityFindingKind = ReactivityKind | 'footgun'
43
+
44
+ export interface ReactivityFinding {
45
+ /** Structural codegen decision, or `'footgun'` for a detected anti-pattern. */
46
+ kind: ReactivityFindingKind
47
+ /** 1-based line. */
48
+ line: number
49
+ /** 0-based column. */
50
+ column: number
51
+ /** 1-based end line. */
52
+ endLine: number
53
+ /** 0-based end column. */
54
+ endColumn: number
55
+ /** Editor-facing one-liner. For footguns, the detector's message. */
56
+ detail: string
57
+ /**
58
+ * For `'footgun'` findings: the static-detector code (e.g.
59
+ * `props-destructured`) so the editor surface can deep-link the
60
+ * anti-pattern catalogue. Absent for structural findings.
61
+ */
62
+ code?: PyreonDiagnosticCode
63
+ /** For `'footgun'` findings: whether a mechanical auto-fix is safe. */
64
+ fixable?: boolean
65
+ }
66
+
67
+ export interface AnalyzeReactivityResult {
68
+ /** Sorted (line, column) findings — structural facts + footguns merged. */
69
+ findings: ReactivityFinding[]
70
+ /**
71
+ * Raw compiler spans (pre-merge), kept so the drift gate can assert the
72
+ * lens kind faithfully records the codegen decision without re-deriving.
73
+ */
74
+ spans: ReactivitySpan[]
75
+ }
76
+
77
+ function spanToFinding(s: ReactivitySpan): ReactivityFinding {
78
+ return {
79
+ kind: s.kind,
80
+ line: s.line,
81
+ column: s.column,
82
+ endLine: s.endLine,
83
+ endColumn: s.endColumn,
84
+ detail: s.detail,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
90
+ *
91
+ * @param code Source text (`.tsx` / `.jsx` / `.ts`).
92
+ * @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
93
+ * @param options `knownSignals` is forwarded to the compiler so
94
+ * cross-module imported signals are auto-call-aware.
95
+ *
96
+ * @example
97
+ * const { findings } = analyzeReactivity(
98
+ * `function C(){ const {x}=props; return <div>{count()}</div> }`,
99
+ * )
100
+ * // → footgun(props-destructured) on `{x}`, reactive on `count()`
101
+ */
102
+ export function analyzeReactivity(
103
+ code: string,
104
+ filename = 'input.tsx',
105
+ options: { knownSignals?: string[] } = {},
106
+ ): AnalyzeReactivityResult {
107
+ let spans: ReactivitySpan[] = []
108
+ try {
109
+ const r = transformJSX_JS(code, filename, {
110
+ reactivityLens: true,
111
+ ...(options.knownSignals ? { knownSignals: options.knownSignals } : {}),
112
+ })
113
+ spans = r.reactivityLens ?? []
114
+ } catch {
115
+ // Parse failure → no structural facts. Footguns may still be derivable
116
+ // (detectPyreonPatterns uses the TS compiler API independently).
117
+ spans = []
118
+ }
119
+
120
+ const findings: ReactivityFinding[] = spans.map(spanToFinding)
121
+
122
+ let footguns: ReturnType<typeof detectPyreonPatterns> = []
123
+ try {
124
+ footguns = detectPyreonPatterns(code, filename)
125
+ } catch {
126
+ footguns = []
127
+ }
128
+ for (const d of footguns) {
129
+ // detectPyreonPatterns gives 1-based line / 0-based column + `current`
130
+ // (the offending source text). Approximate the end as same-line +
131
+ // current length; multi-line `current` is rare and the editor only
132
+ // needs a reasonable highlight range.
133
+ const firstLineLen = d.current.split('\n')[0]?.length ?? d.current.length
134
+ findings.push({
135
+ kind: 'footgun',
136
+ line: d.line,
137
+ column: d.column,
138
+ endLine: d.line,
139
+ endColumn: d.column + firstLineLen,
140
+ detail: d.message,
141
+ code: d.code,
142
+ fixable: d.fixable,
143
+ })
144
+ }
145
+
146
+ findings.sort((a, b) => a.line - b.line || a.column - b.column)
147
+ return { findings, spans }
148
+ }
149
+
150
+ const KIND_BADGE: Record<ReactivityFindingKind, string> = {
151
+ reactive: '◆ live',
152
+ 'reactive-prop': '◆ live prop',
153
+ 'reactive-attr': '◆ live attr',
154
+ 'static-text': '○ baked once',
155
+ 'hoisted-static': '○ hoisted static',
156
+ footgun: '⚠ footgun',
157
+ }
158
+
159
+ /**
160
+ * Render an annotated source view for CLI / debugging — every analyzed line
161
+ * followed by its reactivity findings. Not the production surface (that's the
162
+ * LSP inlay hints); this is the spike's "can you see reactivity flow" probe
163
+ * and a stable diff target for tests.
164
+ */
165
+ export function formatReactivityLens(
166
+ code: string,
167
+ result: AnalyzeReactivityResult,
168
+ ): string {
169
+ const lines = code.split('\n')
170
+ const byLine = new Map<number, ReactivityFinding[]>()
171
+ for (const f of result.findings) {
172
+ const arr = byLine.get(f.line) ?? []
173
+ arr.push(f)
174
+ byLine.set(f.line, arr)
175
+ }
176
+ const out: string[] = []
177
+ for (let i = 0; i < lines.length; i++) {
178
+ const lineNo = i + 1
179
+ out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`)
180
+ const fs = byLine.get(lineNo)
181
+ if (fs) {
182
+ for (const f of fs) {
183
+ const pad = ' '.repeat(7 + f.column)
184
+ const tag = f.code ? ` [${f.code}]` : ''
185
+ out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`)
186
+ }
187
+ }
188
+ }
189
+ return out.join('\n')
190
+ }