@kirrosh/zond 0.14.0 → 0.17.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +4 -4
  4. package/src/cli/commands/ci-init.ts +12 -1
  5. package/src/cli/commands/coverage.ts +21 -1
  6. package/src/cli/commands/db.ts +121 -0
  7. package/src/cli/commands/describe.ts +60 -0
  8. package/src/cli/commands/export.ts +144 -0
  9. package/src/cli/commands/generate.ts +158 -0
  10. package/src/cli/commands/guide.ts +127 -0
  11. package/src/cli/commands/init.ts +57 -0
  12. package/src/cli/commands/request.ts +57 -0
  13. package/src/cli/commands/run.ts +74 -14
  14. package/src/cli/commands/serve.ts +62 -3
  15. package/src/cli/commands/sync.ts +240 -0
  16. package/src/cli/commands/validate.ts +18 -2
  17. package/src/cli/index.ts +258 -17
  18. package/src/cli/json-envelope.ts +19 -0
  19. package/src/core/diagnostics/db-analysis.ts +423 -0
  20. package/src/core/diagnostics/failure-hints.ts +40 -0
  21. package/src/core/exporter/postman.ts +963 -0
  22. package/src/core/generator/data-factory.ts +55 -9
  23. package/src/core/generator/describe.ts +250 -0
  24. package/src/core/generator/guide-builder.ts +20 -0
  25. package/src/core/generator/index.ts +1 -1
  26. package/src/core/generator/openapi-reader.ts +6 -0
  27. package/src/core/generator/serializer.ts +17 -2
  28. package/src/core/generator/suite-generator.ts +291 -29
  29. package/src/core/generator/types.ts +1 -0
  30. package/src/core/meta/meta-store.ts +78 -0
  31. package/src/core/meta/types.ts +21 -0
  32. package/src/core/parser/schema.ts +12 -2
  33. package/src/core/parser/types.ts +12 -1
  34. package/src/core/parser/variables.ts +3 -0
  35. package/src/core/parser/yaml-parser.ts +2 -1
  36. package/src/core/runner/assertions.ts +44 -20
  37. package/src/core/runner/execute-run.ts +31 -8
  38. package/src/core/runner/executor.ts +35 -8
  39. package/src/core/runner/http-client.ts +1 -1
  40. package/src/core/runner/send-request.ts +94 -0
  41. package/src/core/runner/types.ts +2 -0
  42. package/src/core/sync/spec-differ.ts +38 -0
  43. package/src/db/queries.ts +4 -2
  44. package/src/db/schema.ts +11 -3
  45. package/src/web/views/suites-tab.ts +1 -1
  46. package/src/cli/commands/mcp.ts +0 -16
  47. package/src/mcp/descriptions.ts +0 -71
  48. package/src/mcp/server.ts +0 -45
  49. package/src/mcp/tools/ci-init.ts +0 -54
  50. package/src/mcp/tools/coverage-analysis.ts +0 -141
  51. package/src/mcp/tools/describe-endpoint.ts +0 -242
  52. package/src/mcp/tools/generate-and-save.ts +0 -202
  53. package/src/mcp/tools/manage-server.ts +0 -86
  54. package/src/mcp/tools/query-db.ts +0 -300
  55. package/src/mcp/tools/run-tests.ts +0 -115
  56. package/src/mcp/tools/save-test-suite.ts +0 -218
  57. package/src/mcp/tools/send-request.ts +0 -97
  58. package/src/mcp/tools/set-work-dir.ts +0 -35
  59. package/src/mcp/tools/setup-api.ts +0 -88
@@ -33,15 +33,18 @@ export function generateFromSchema(
33
33
  return schema.enum[0];
34
34
  }
35
35
 
36
+ // uuid format overrides type (e.g. integer fields with format: uuid)
37
+ if (schema.format === "uuid") return "{{$uuid}}";
38
+
36
39
  switch (schema.type) {
37
40
  case "string":
38
41
  return guessStringPlaceholder(schema, propertyName);
39
42
 
40
43
  case "integer":
41
- return guessIntPlaceholder(propertyName);
44
+ return guessIntPlaceholder(propertyName, schema);
42
45
 
43
46
  case "number":
44
- return "{{$randomInt}}";
47
+ return 29.99;
45
48
 
46
49
  case "boolean":
47
50
  return true;
@@ -81,11 +84,41 @@ export function generateFromSchema(
81
84
  }
82
85
  }
83
86
 
87
+ /**
88
+ * Generate a multipart body object from an OpenAPI multipart/form-data schema.
89
+ * Binary fields (format: binary/byte) become file upload objects; all others become strings.
90
+ */
91
+ export function generateMultipartFromSchema(
92
+ schema: OpenAPIV3.SchemaObject,
93
+ ): Record<string, unknown> {
94
+ const result: Record<string, unknown> = {};
95
+
96
+ if (!schema.properties) return result;
97
+
98
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
99
+ const s = propSchema as OpenAPIV3.SchemaObject;
100
+ if (s.format === "binary" || s.format === "byte") {
101
+ result[key] = { file: `./fixtures/${key}.bin`, content_type: "application/octet-stream" };
102
+ } else {
103
+ const val = generateFromSchema(s, key);
104
+ result[key] = val;
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
84
111
  function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
85
112
  // Format-based
86
113
  if (schema.format === "email") return "{{$randomEmail}}";
87
114
  if (schema.format === "uuid") return "{{$uuid}}";
88
- if (schema.format === "date-time" || schema.format === "date") return "2025-01-01T00:00:00Z";
115
+ if (schema.format === "date-time") return "2025-01-01T00:00:00Z";
116
+ if (schema.format === "date") return "2025-01-01";
117
+ if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
118
+ if (schema.format === "hostname") return "example.com";
119
+ if (schema.format === "ipv4") return "192.168.1.1";
120
+ if (schema.format === "ipv6") return "::1";
121
+ if (schema.format === "password") return "TestPass123!";
89
122
 
90
123
  // Name-based heuristics
91
124
  if (name) {
@@ -99,17 +132,30 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
99
132
  if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
100
133
  return "{{$randomName}}";
101
134
  }
135
+ if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
136
+ return "https://example.com/test";
137
+ }
138
+ if (lower === "password" || lower.endsWith("_password")) {
139
+ return "TestPass123!";
140
+ }
141
+ if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) {
142
+ return "+1234567890";
143
+ }
102
144
  }
103
145
 
104
146
  return "{{$randomString}}";
105
147
  }
106
148
 
107
- function guessIntPlaceholder(name?: string): string {
108
- if (name) {
109
- const lower = name.toLowerCase();
110
- if (lower === "id" || lower.endsWith("_id") || lower.endsWith("Id")) {
111
- return "{{$randomInt}}";
112
- }
149
+ function guessIntPlaceholder(name?: string, schema?: OpenAPIV3.SchemaObject): number | string {
150
+ const min = schema?.minimum;
151
+ const max = schema?.maximum;
152
+ if (max !== undefined) {
153
+ // Use a safe concrete value within the declared range
154
+ const lo = min !== undefined && min > 0 ? min : 1;
155
+ return Math.min(lo, max);
156
+ }
157
+ if (min !== undefined && min > 0) {
158
+ return min;
113
159
  }
114
160
  return "{{$randomInt}}";
115
161
  }
@@ -0,0 +1,250 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+ import { readOpenApiSpec } from "./openapi-reader.ts";
3
+ import { decycleSchema } from "./schema-utils.ts";
4
+
5
+ export interface DescribeEndpointResult {
6
+ method: string;
7
+ path: string;
8
+ operationId?: string;
9
+ summary?: string;
10
+ description?: string;
11
+ tags?: string[];
12
+ deprecated: boolean;
13
+ security: string[];
14
+ parameters: Record<string, object[]>;
15
+ requestBody?: object;
16
+ responses: Record<string, object>;
17
+ testSnippet: string;
18
+ }
19
+
20
+ export interface CompactEndpoint {
21
+ method: string;
22
+ path: string;
23
+ operationId?: string;
24
+ summary?: string;
25
+ deprecated: boolean;
26
+ }
27
+
28
+ export function generateTestSnippet(params: {
29
+ method: string;
30
+ path: string;
31
+ operationId?: string;
32
+ pathParams: string[];
33
+ queryParams: Array<{ name: string; required?: boolean }>;
34
+ requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
35
+ hasSecurity: boolean;
36
+ successStatus: string;
37
+ }): string {
38
+ const { method, path, operationId, queryParams, requestBody, hasSecurity, successStatus } = params;
39
+
40
+ const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
41
+ const url = `{{base_url}}${urlPath}`;
42
+
43
+ const lines: string[] = [];
44
+ const testName = operationId ?? `${method} ${path}`;
45
+ lines.push(`- name: "${testName}"`);
46
+ lines.push(` ${method}: "${url}"`);
47
+
48
+ if (hasSecurity) {
49
+ lines.push(` headers:`);
50
+ lines.push(` Authorization: "Bearer {{auth_token}}"`);
51
+ }
52
+
53
+ const requiredQuery = queryParams.filter(p => p.required);
54
+ if (requiredQuery.length > 0) {
55
+ lines.push(` query:`);
56
+ for (const p of requiredQuery) {
57
+ lines.push(` ${p.name}: "{{${p.name}}}"`);
58
+ }
59
+ }
60
+
61
+ if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
62
+ const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
63
+ const required = Array.isArray(schema?.required) ? schema.required : [];
64
+ const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
65
+ if (properties && Object.keys(properties).length > 0) {
66
+ lines.push(` json:`);
67
+ for (const [propName, propSchema] of Object.entries(properties)) {
68
+ if (!required.includes(propName)) continue;
69
+ const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
70
+ const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
71
+ lines.push(` ${propName}: ${placeholder}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ lines.push(` expect:`);
77
+ lines.push(` status: ${successStatus}`);
78
+
79
+ return lines.join("\n");
80
+ }
81
+
82
+ export async function describeEndpoint(
83
+ specPath: string,
84
+ method: string,
85
+ endpointPath: string,
86
+ options?: { insecure?: boolean },
87
+ ): Promise<DescribeEndpointResult> {
88
+ const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
89
+
90
+ const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
91
+ const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
92
+
93
+ let operation: OpenAPIV3.OperationObject | undefined;
94
+ let resolvedPath = normalizedPath;
95
+ const paths = doc.paths ?? {};
96
+
97
+ if (paths[normalizedPath]?.[methodLower]) {
98
+ operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
99
+ } else {
100
+ const lowerTarget = normalizedPath.toLowerCase();
101
+ for (const [p, pathItem] of Object.entries(paths)) {
102
+ if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
103
+ operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
104
+ resolvedPath = p;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+
110
+ if (!operation) {
111
+ const available = Object.entries(paths).flatMap(([p, pathItem]) =>
112
+ Object.keys(pathItem ?? {})
113
+ .filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
114
+ .map(k => `${k.toUpperCase()} ${p}`)
115
+ ).sort();
116
+ throw new Error(`Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec. Available: ${available.join(", ")}`);
117
+ }
118
+
119
+ const pathItem = paths[resolvedPath] ?? {};
120
+
121
+ const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
122
+ const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
123
+
124
+ const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
125
+ for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
126
+ for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
127
+
128
+ const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
129
+ for (const p of paramMap.values()) {
130
+ const loc = p.in in grouped ? p.in : "query";
131
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
132
+ grouped[loc]!.push({
133
+ name: p.name,
134
+ required: p.required ?? false,
135
+ ...(schema?.type ? { type: schema.type } : {}),
136
+ ...(schema?.format ? { format: schema.format } : {}),
137
+ ...(schema?.enum ? { enum: schema.enum } : {}),
138
+ ...(schema?.default !== undefined ? { default: schema.default } : {}),
139
+ ...(p.description ? { description: p.description } : {}),
140
+ });
141
+ }
142
+
143
+ let requestBody: object | undefined;
144
+ if (operation.requestBody) {
145
+ const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
146
+ const contentTypes = Object.keys(rb.content ?? {});
147
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
148
+ const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
149
+ requestBody = {
150
+ required: rb.required ?? false,
151
+ ...(preferredCt ? { contentType: preferredCt } : {}),
152
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
153
+ ...(rb.description ? { description: rb.description } : {}),
154
+ };
155
+ }
156
+
157
+ const responses: Record<string, object> = {};
158
+ for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
159
+ const resp = respObj as OpenAPIV3.ResponseObject;
160
+ const contentTypes = Object.keys(resp.content ?? {});
161
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
162
+ const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
163
+
164
+ const headers: Record<string, object> = {};
165
+ for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
166
+ const h = hObj as OpenAPIV3.HeaderObject;
167
+ headers[hName] = {
168
+ ...(h.description ? { description: h.description } : {}),
169
+ ...(h.schema ? { schema: h.schema } : {}),
170
+ };
171
+ }
172
+
173
+ responses[statusCode] = {
174
+ description: resp.description,
175
+ headers,
176
+ ...(preferredCt ? { contentType: preferredCt } : {}),
177
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
178
+ };
179
+ }
180
+
181
+ const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
182
+ const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
183
+ const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
184
+
185
+ const responseCodes = Object.keys(operation.responses ?? {});
186
+ const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
187
+
188
+ const pathParamNames = [...paramMap.values()]
189
+ .filter(p => p.in === "path")
190
+ .map(p => p.name);
191
+ const queryParamsList = [...paramMap.values()]
192
+ .filter(p => p.in === "query")
193
+ .map(p => ({ name: p.name, required: p.required }));
194
+ const reqBodyForSnippet = requestBody
195
+ ? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
196
+ : undefined;
197
+
198
+ const testSnippet = generateTestSnippet({
199
+ method: method.toUpperCase(),
200
+ path: resolvedPath,
201
+ operationId: operation.operationId,
202
+ pathParams: pathParamNames,
203
+ queryParams: queryParamsList,
204
+ requestBody: reqBodyForSnippet,
205
+ hasSecurity: securityNames.length > 0,
206
+ successStatus,
207
+ });
208
+
209
+ const result: DescribeEndpointResult = {
210
+ method: method.toUpperCase(),
211
+ path: resolvedPath,
212
+ ...(operation.operationId ? { operationId: operation.operationId } : {}),
213
+ ...(operation.summary ? { summary: operation.summary } : {}),
214
+ ...(operation.description ? { description: operation.description } : {}),
215
+ ...(operation.tags?.length ? { tags: operation.tags } : {}),
216
+ deprecated: operation.deprecated ?? false,
217
+ security: securityNames,
218
+ parameters: grouped,
219
+ ...(requestBody ? { requestBody } : {}),
220
+ responses,
221
+ testSnippet,
222
+ };
223
+
224
+ return decycleSchema(result) as DescribeEndpointResult;
225
+ }
226
+
227
+ export async function describeCompact(
228
+ specPath: string,
229
+ options?: { insecure?: boolean },
230
+ ): Promise<CompactEndpoint[]> {
231
+ const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
232
+ const paths = doc.paths ?? {};
233
+ const result: CompactEndpoint[] = [];
234
+
235
+ for (const [path, pathItem] of Object.entries(paths)) {
236
+ for (const method of ["get","post","put","patch","delete","head","options","trace"]) {
237
+ const op = (pathItem as any)?.[method] as OpenAPIV3.OperationObject | undefined;
238
+ if (!op) continue;
239
+ result.push({
240
+ method: method.toUpperCase(),
241
+ path,
242
+ ...(op.operationId ? { operationId: op.operationId } : {}),
243
+ ...(op.summary ? { summary: op.summary } : {}),
244
+ deprecated: op.deprecated ?? false,
245
+ });
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
@@ -196,6 +196,26 @@ Use spec paths with \`{param}\` placeholders in the path for coverage to match:
196
196
  - Spec says \`GET /products/{id}\` → write \`GET: /products/1\` (hardcode the value)
197
197
  - Coverage scanner matches test paths against spec paths automatically
198
198
 
199
+ ### Suite variable isolation — IMPORTANT
200
+ Each suite runs in its own variable scope. Captured variables (via \`capture:\`) do NOT propagate between suites.
201
+ If multiple suites need auth, each suite must either:
202
+ - Include its own login step with \`capture: auth_token\`
203
+ - Or use \`auth_token\` from \`.env.yaml\` (pre-configured, no capture needed)
204
+
205
+ Do NOT create a separate "setup" suite expecting other suites to use its captures.
206
+
207
+ ### ETag / Conditional Requests
208
+ If-Match and If-None-Match require escaped quotes around the ETag value:
209
+ \`\`\`yaml
210
+ - name: Update with ETag
211
+ PUT: /items/{{item_id}}
212
+ headers:
213
+ If-Match: "\\"{{etag}}\\""
214
+ json: { name: "updated" }
215
+ expect:
216
+ status: 200
217
+ \`\`\`
218
+
199
219
  ### CRITICAL: Never mask server errors
200
220
  - If an endpoint returns 500 — do NOT change expect to \`status: 500\`. Keep \`status: 200\` and let the test fail.
201
221
  - A failing test = signal about an API bug. The goal is NOT "all tests green" but "tests reflect expected behavior".
@@ -9,4 +9,4 @@ export { compressEndpointsWithSchemas, buildGenerationGuide } from "./guide-buil
9
9
  export type { GuideOptions } from "./guide-builder.ts";
10
10
  export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
11
11
  export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
12
- export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, findUnresolvedVars } from "./suite-generator.ts";
12
+ export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
@@ -124,6 +124,11 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
124
124
  const securityReqs = operation.security ?? doc.security ?? [];
125
125
  const security = securityReqs.flatMap((req) => Object.keys(req));
126
126
 
127
+ // ETag optimistic locking: detect if endpoint requires If-Match header
128
+ const requiresEtag =
129
+ responses.some(r => r.statusCode === 412) ||
130
+ parameters.some(p => p.name.toLowerCase() === "if-match" && p.in === "header");
131
+
127
132
  endpoints.push({
128
133
  path,
129
134
  method: method.toUpperCase(),
@@ -137,6 +142,7 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
137
142
  responses,
138
143
  security,
139
144
  deprecated: operation.deprecated ?? false,
145
+ requiresEtag,
140
146
  });
141
147
  }
142
148
  }
@@ -29,11 +29,13 @@ export interface RawStep {
29
29
  expect: {
30
30
  status?: number;
31
31
  body?: Record<string, Record<string, string>>;
32
+ headers?: Record<string, unknown>;
32
33
  };
33
34
  }
34
35
 
35
36
  export interface RawSuite {
36
37
  name: string;
38
+ setup?: boolean;
37
39
  tags?: string[];
38
40
  folder?: string;
39
41
  fileStem?: string;
@@ -49,6 +51,9 @@ export interface RawSuite {
49
51
  export function serializeSuite(suite: RawSuite): string {
50
52
  const lines: string[] = [];
51
53
  lines.push(`name: ${yamlScalar(suite.name)}`);
54
+ if (suite.setup) {
55
+ lines.push("setup: true");
56
+ }
52
57
  if (suite.tags && suite.tags.length > 0) {
53
58
  lines.push(`tags: [${suite.tags.join(", ")}]`);
54
59
  }
@@ -167,10 +172,20 @@ function serializeValue(value: unknown, indent: number, lines: string[]): void {
167
172
  const entries = Object.entries(item as Record<string, unknown>);
168
173
  if (entries.length > 0) {
169
174
  const [firstKey, firstVal] = entries[0]!;
170
- lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
175
+ if (typeof firstVal === "object" && firstVal !== null) {
176
+ lines.push(`${prefix}- ${firstKey}:`);
177
+ serializeValue(firstVal, indent + 1, lines);
178
+ } else {
179
+ lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
180
+ }
171
181
  for (let i = 1; i < entries.length; i++) {
172
182
  const [k, v] = entries[i]!;
173
- lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
183
+ if (typeof v === "object" && v !== null) {
184
+ lines.push(`${prefix} ${k}:`);
185
+ serializeValue(v, indent + 1, lines);
186
+ } else {
187
+ lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
188
+ }
174
189
  }
175
190
  } else {
176
191
  lines.push(`${prefix}- {}`);