@flisk/analyze-tracking 0.9.0 → 0.9.1

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 CHANGED
@@ -39,6 +39,8 @@ npx @flisk/analyze-tracking /path/to/project [options]
39
39
 
40
40
  If you have your own in-house tracker or a wrapper function that calls other tracking libraries, you can specify the function signature with the `-c` or `--customFunction` option.
41
41
 
42
+ #### Standard Custom Function Format
43
+
42
44
  Your function signature should be in the following format:
43
45
  ```js
44
46
  yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES, customFieldOne, customFieldTwo)
@@ -57,11 +59,45 @@ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES)
57
59
 
58
60
  If your function follows the standard format `yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES)`, you can simply pass in `yourCustomTrackFunctionName` to `--customFunction` as a shorthand.
59
61
 
62
+ #### Method-Name-as-Event Format
63
+
64
+ For tracking patterns where the method name itself is the event name (e.g., `yourClass.yourEventName({...})`), use the special `EVENT_NAME` placeholder in the method position:
65
+
66
+ ```js
67
+ yourClass.EVENT_NAME(PROPERTIES)
68
+ ```
69
+
70
+ This pattern tells the analyzer that:
71
+ - `yourClass` is the object name to match
72
+ - The method name after the dot (e.g., `viewItemList`, `addToCart`) is the event name
73
+ - `PROPERTIES` is the properties object (defaults to the first argument if not specified)
74
+
75
+ **Example:**
76
+ ```typescript
77
+ // Code in your project:
78
+ yourClass.viewItemList({ items: [...] });
79
+ yourClass.addToCart({ item: {...}, value: 100 });
80
+ yourClass.purchase({ userId: '123', value: 100 });
81
+
82
+ // Command:
83
+ npx @flisk/analyze-tracking /path/to/project --customFunction "yourClass.EVENT_NAME(PROPERTIES)"
84
+ ```
85
+
86
+ This will detect:
87
+ - Event: `viewItemList` with properties from the first argument
88
+ - Event: `addToCart` with properties from the first argument
89
+ - Event: `purchase` with properties from the first argument
90
+
91
+ _**Note:** This pattern is currently only supported for JavaScript and TypeScript code._
92
+
93
+ #### Multiple Custom Functions
94
+
60
95
  You can also pass in multiple custom function signatures by passing in the `--customFunction` option multiple times or by passing in a space-separated list of function signatures.
61
96
 
62
97
  ```sh
63
98
  npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
64
99
  npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
100
+ npx @flisk/analyze-tracking /path/to/project -c "yourClass.EVENT_NAME(PROPERTIES)" "customTrack(EVENT_NAME, PROPERTIES)"
65
101
  ```
66
102
 
67
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/schema.json CHANGED
@@ -142,6 +142,16 @@
142
142
  "items": {
143
143
  "$ref": "#/definitions/property",
144
144
  "description": "Schema for array items when type is 'array'"
145
+ },
146
+ "values": {
147
+ "type": "array",
148
+ "items": {
149
+ "oneOf": [
150
+ { "type": "string" },
151
+ { "type": "number" }
152
+ ]
153
+ },
154
+ "description": "Possible values when type is 'enum'"
145
155
  }
146
156
  },
147
157
  "required": [
@@ -8,16 +8,25 @@ const { ANALYTICS_PROVIDERS, NODE_TYPES } = require('../constants');
8
8
  /**
9
9
  * Detects the analytics provider from a CallExpression node
10
10
  * @param {Object} node - AST CallExpression node
11
- * @param {string} [customFunction] - Custom function name to detect
11
+ * @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
12
12
  * @returns {string} The detected analytics source or 'unknown'
13
13
  */
14
- function detectAnalyticsSource(node, customFunction) {
14
+ function detectAnalyticsSource(node, customFunctionOrConfig) {
15
15
  if (!node.callee) {
16
16
  return 'unknown';
17
17
  }
18
18
 
19
19
  // Check for custom function first
20
- if (customFunction && isCustomFunction(node, customFunction)) {
20
+ // Support both old string format and new config object format
21
+ const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
22
+ const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);
23
+
24
+ if (customConfig?.isMethodAsEvent) {
25
+ // Method-as-event pattern: match any method on the specified object
26
+ if (isMethodAsEventFunction(node, customConfig)) {
27
+ return 'custom';
28
+ }
29
+ } else if (customFunction && isCustomFunction(node, customFunction)) {
21
30
  return 'custom';
22
31
  }
23
32
 
@@ -36,6 +45,31 @@ function detectAnalyticsSource(node, customFunction) {
36
45
  return 'unknown';
37
46
  }
38
47
 
48
+ /**
49
+ * Checks if the node matches a method-as-event custom function pattern
50
+ * @param {Object} node - AST CallExpression node
51
+ * @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
52
+ * @returns {boolean}
53
+ */
54
+ function isMethodAsEventFunction(node, customConfig) {
55
+ if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
56
+ return false;
57
+ }
58
+
59
+ // Must be a MemberExpression: objectName.methodName(...)
60
+ if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
61
+ return false;
62
+ }
63
+
64
+ // The object part must match the configured objectName
65
+ const objectNode = node.callee.object;
66
+ if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
67
+ return false;
68
+ }
69
+
70
+ return objectNode.name === customConfig.objectName;
71
+ }
72
+
39
73
  /**
40
74
  * Checks if the node is a custom function call
41
75
  * @param {Object} node - AST CallExpression node
@@ -122,7 +156,7 @@ function detectFunctionBasedProvider(node) {
122
156
  }
123
157
 
124
158
  const functionName = node.callee.name;
125
-
159
+
126
160
  for (const provider of Object.values(ANALYTICS_PROVIDERS)) {
127
161
  if (provider.type === 'function' && provider.functionName === functionName) {
128
162
  return provider.name;
@@ -72,15 +72,15 @@ function extractSnowplowEvent(node, constantMap) {
72
72
 
73
73
  // tracker.track(buildStructEvent({ action: 'event_name', ... }))
74
74
  const firstArg = node.arguments[0];
75
-
76
- if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&
75
+
76
+ if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&
77
77
  firstArg.arguments.length > 0) {
78
78
  const structEventArg = firstArg.arguments[0];
79
-
79
+
80
80
  if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
81
81
  const actionProperty = findPropertyByKey(structEventArg, 'action');
82
82
  const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;
83
-
83
+
84
84
  return { eventName, propertiesNode: structEventArg };
85
85
  }
86
86
  }
@@ -119,7 +119,7 @@ function extractGTMEvent(node, constantMap) {
119
119
 
120
120
  // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
121
121
  const firstArg = node.arguments[0];
122
-
122
+
123
123
  if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) {
124
124
  return { eventName: null, propertiesNode: null };
125
125
  }
@@ -131,11 +131,11 @@ function extractGTMEvent(node, constantMap) {
131
131
  }
132
132
 
133
133
  const eventName = getStringValue(eventProperty.value, constantMap);
134
-
134
+
135
135
  // Create a modified properties node without the 'event' property
136
136
  const modifiedPropertiesNode = {
137
137
  ...firstArg,
138
- properties: firstArg.properties.filter(prop =>
138
+ properties: firstArg.properties.filter(prop =>
139
139
  prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event')
140
140
  )
141
141
  };
@@ -171,10 +171,27 @@ function extractDefaultEvent(node, constantMap) {
171
171
  function extractCustomEvent(node, constantMap, customConfig) {
172
172
  const args = node.arguments || [];
173
173
 
174
- const eventArg = args[customConfig?.eventIndex ?? 0];
175
- const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
174
+ let eventName;
175
+ let propertiesArg;
176
+
177
+ if (customConfig?.isMethodAsEvent) {
178
+ // Method-as-event pattern: event name comes from the method name
179
+ if (node.callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
180
+ node.callee.property.type === NODE_TYPES.IDENTIFIER) {
181
+ eventName = node.callee.property.name;
182
+ } else {
183
+ // Fallback: could not extract method name
184
+ eventName = null;
185
+ }
176
186
 
177
- const eventName = getStringValue(eventArg, constantMap);
187
+ // Properties are at the configured index (default 0)
188
+ propertiesArg = args[customConfig?.propertiesIndex ?? 0];
189
+ } else {
190
+ // Standard custom function pattern: event name comes from argument
191
+ const eventArg = args[customConfig?.eventIndex ?? 0];
192
+ propertiesArg = args[customConfig?.propertiesIndex ?? 1];
193
+ eventName = getStringValue(eventArg, constantMap);
194
+ }
178
195
 
179
196
  const extraArgs = {};
180
197
  if (customConfig && customConfig.extraParams) {
@@ -274,8 +291,8 @@ function getStringValue(node, constantMap = {}) {
274
291
  */
275
292
  function findPropertyByKey(objectNode, key) {
276
293
  if (!objectNode.properties) return null;
277
-
278
- return objectNode.properties.find(prop =>
294
+
295
+ return objectNode.properties.find(prop =>
279
296
  prop.key && (prop.key.name === key || prop.key.value === key)
280
297
  );
281
298
  }
@@ -53,7 +53,7 @@ class ParseError extends Error {
53
53
  */
54
54
  function parseFile(filePath) {
55
55
  let code;
56
-
56
+
57
57
  try {
58
58
  code = fs.readFileSync(filePath, 'utf8');
59
59
  } catch (error) {
@@ -72,16 +72,33 @@ function parseFile(filePath) {
72
72
  // ---------------------------------------------
73
73
 
74
74
  /**
75
- * Determines whether a CallExpression node matches the provided custom function name.
76
- * Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
75
+ * Determines whether a CallExpression node matches the provided custom function configuration.
76
+ * Supports both simple identifiers (e.g. myTrack), dot-separated members (e.g. Custom.track),
77
+ * and method-as-event patterns (e.g. eventCalls.EVENT_NAME).
77
78
  * The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
78
79
  * circular dependencies.
79
80
  * @param {Object} node – CallExpression AST node
80
- * @param {string} fnName – Custom function name (could include dots)
81
+ * @param {Object} customConfig – Custom function configuration object
81
82
  * @returns {boolean}
82
83
  */
83
- function nodeMatchesCustomFunction(node, fnName) {
84
- if (!fnName || !node.callee) return false;
84
+ function nodeMatchesCustomFunction(node, customConfig) {
85
+ if (!customConfig || !node.callee) return false;
86
+
87
+ // Handle method-as-event pattern
88
+ if (customConfig.isMethodAsEvent && customConfig.objectName) {
89
+ if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
90
+ return false;
91
+ }
92
+ const objectNode = node.callee.object;
93
+ if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
94
+ return false;
95
+ }
96
+ return objectNode.name === customConfig.objectName;
97
+ }
98
+
99
+ // Handle standard custom function patterns
100
+ const fnName = customConfig.functionName;
101
+ if (!fnName) return false;
85
102
 
86
103
  // Support chained calls in function name by stripping trailing parens from each segment
87
104
  const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
@@ -204,7 +221,7 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
204
221
  // Attempt to match any custom function first to avoid mis-classifying built-in providers
205
222
  if (Array.isArray(customConfigs) && customConfigs.length > 0) {
206
223
  for (const cfg of customConfigs) {
207
- if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
224
+ if (cfg && nodeMatchesCustomFunction(node, cfg)) {
208
225
  matchedCustomConfig = cfg;
209
226
  break;
210
227
  }
@@ -237,7 +254,8 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
237
254
  * @returns {Object|null} Extracted event or null
238
255
  */
239
256
  function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
240
- const source = detectAnalyticsSource(node, customConfig?.functionName);
257
+ // Pass the full customConfig object (not just functionName) to support method-as-event patterns
258
+ const source = detectAnalyticsSource(node, customConfig || null);
241
259
  if (source === 'unknown') {
242
260
  return null;
243
261
  }
@@ -9,16 +9,25 @@ const { ANALYTICS_PROVIDERS } = require('../constants');
9
9
  /**
10
10
  * Detects the analytics provider from a CallExpression node
11
11
  * @param {Object} node - TypeScript CallExpression node
12
- * @param {string} [customFunction] - Custom function name to detect
12
+ * @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
13
13
  * @returns {string} The detected analytics source or 'unknown'
14
14
  */
15
- function detectAnalyticsSource(node, customFunction) {
15
+ function detectAnalyticsSource(node, customFunctionOrConfig) {
16
16
  if (!node.expression) {
17
17
  return 'unknown';
18
18
  }
19
19
 
20
20
  // Check for custom function first
21
- if (customFunction && isCustomFunction(node, customFunction)) {
21
+ // Support both old string format and new config object format
22
+ const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
23
+ const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);
24
+
25
+ if (customConfig?.isMethodAsEvent) {
26
+ // Method-as-event pattern: match any method on the specified object
27
+ if (isMethodAsEventFunction(node, customConfig)) {
28
+ return 'custom';
29
+ }
30
+ } else if (customFunction && isCustomFunction(node, customFunction)) {
22
31
  return 'custom';
23
32
  }
24
33
 
@@ -37,6 +46,31 @@ function detectAnalyticsSource(node, customFunction) {
37
46
  return 'unknown';
38
47
  }
39
48
 
49
+ /**
50
+ * Checks if the node matches a method-as-event custom function pattern
51
+ * @param {Object} node - TypeScript CallExpression node
52
+ * @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
53
+ * @returns {boolean}
54
+ */
55
+ function isMethodAsEventFunction(node, customConfig) {
56
+ if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
57
+ return false;
58
+ }
59
+
60
+ // Must be a PropertyAccessExpression: objectName.methodName(...)
61
+ if (!ts.isPropertyAccessExpression(node.expression)) {
62
+ return false;
63
+ }
64
+
65
+ // The object part must match the configured objectName
66
+ const objectExpr = node.expression.expression;
67
+ if (!ts.isIdentifier(objectExpr)) {
68
+ return false;
69
+ }
70
+
71
+ return objectExpr.escapedText === customConfig.objectName;
72
+ }
73
+
40
74
  /**
41
75
  * Checks if the node is a custom function call
42
76
  * @param {Object} node - TypeScript CallExpression node
@@ -76,10 +76,10 @@ function extractSnowplowEvent(node, checker, sourceFile) {
76
76
 
77
77
  // tracker.track(buildStructEvent({ action: 'event_name', ... }))
78
78
  const firstArg = node.arguments[0];
79
-
79
+
80
80
  // Check if it's a direct buildStructEvent call
81
- if (ts.isCallExpression(firstArg) &&
82
- ts.isIdentifier(firstArg.expression) &&
81
+ if (ts.isCallExpression(firstArg) &&
82
+ ts.isIdentifier(firstArg.expression) &&
83
83
  firstArg.expression.escapedText === 'buildStructEvent' &&
84
84
  firstArg.arguments.length > 0) {
85
85
  const structEventArg = firstArg.arguments[0];
@@ -141,7 +141,7 @@ function extractGTMEvent(node, checker, sourceFile) {
141
141
 
142
142
  // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
143
143
  const firstArg = node.arguments[0];
144
-
144
+
145
145
  if (!ts.isObjectLiteralExpression(firstArg)) {
146
146
  return { eventName: null, propertiesNode: null };
147
147
  }
@@ -153,7 +153,7 @@ function extractGTMEvent(node, checker, sourceFile) {
153
153
  }
154
154
 
155
155
  const eventName = getStringValue(eventProperty.initializer, checker, sourceFile);
156
-
156
+
157
157
  // Create a modified properties node without the 'event' property
158
158
  const modifiedProperties = firstArg.properties.filter(prop => {
159
159
  if (ts.isPropertyAssignment(prop) && prop.name) {
@@ -169,7 +169,7 @@ function extractGTMEvent(node, checker, sourceFile) {
169
169
 
170
170
  // Create a synthetic object literal with the filtered properties
171
171
  const modifiedPropertiesNode = ts.factory.createObjectLiteralExpression(modifiedProperties);
172
-
172
+
173
173
  // Copy source positions for proper analysis
174
174
  if (firstArg.pos !== undefined) {
175
175
  modifiedPropertiesNode.pos = firstArg.pos;
@@ -192,10 +192,31 @@ function extractGTMEvent(node, checker, sourceFile) {
192
192
  function extractCustomEvent(node, checker, sourceFile, customConfig) {
193
193
  const args = node.arguments || [];
194
194
 
195
- const eventArg = args[customConfig?.eventIndex ?? 0];
196
- const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
195
+ let eventName;
196
+ let propertiesArg;
197
+
198
+ if (customConfig?.isMethodAsEvent) {
199
+ // Method-as-event pattern: event name comes from the method name
200
+ if (ts.isPropertyAccessExpression(node.expression)) {
201
+ const methodName = node.expression.name;
202
+ if (methodName && ts.isIdentifier(methodName)) {
203
+ eventName = methodName.escapedText || methodName.text;
204
+ } else {
205
+ // Fallback: could not extract method name
206
+ eventName = null;
207
+ }
208
+ } else {
209
+ eventName = null;
210
+ }
197
211
 
198
- const eventName = getStringValue(eventArg, checker, sourceFile);
212
+ // Properties are at the configured index (default 0)
213
+ propertiesArg = args[customConfig?.propertiesIndex ?? 0];
214
+ } else {
215
+ // Standard custom function pattern: event name comes from argument
216
+ const eventArg = args[customConfig?.eventIndex ?? 0];
217
+ propertiesArg = args[customConfig?.propertiesIndex ?? 1];
218
+ eventName = getStringValue(eventArg, checker, sourceFile);
219
+ }
199
220
 
200
221
  const extraArgs = {};
201
222
  if (customConfig && customConfig.extraParams) {
@@ -320,22 +341,22 @@ function processEventData(eventData, source, filePath, line, functionName, check
320
341
  */
321
342
  function getStringValue(node, checker, sourceFile) {
322
343
  if (!node) return null;
323
-
344
+
324
345
  // Handle string literals (existing behavior)
325
346
  if (ts.isStringLiteral(node)) {
326
347
  return node.text;
327
348
  }
328
-
349
+
329
350
  // Handle property access expressions like TRACKING_EVENTS.ECOMMERCE_PURCHASE
330
351
  if (ts.isPropertyAccessExpression(node)) {
331
352
  return resolvePropertyAccessToString(node, checker, sourceFile);
332
353
  }
333
-
354
+
334
355
  // Handle identifiers that might reference constants
335
356
  if (ts.isIdentifier(node)) {
336
357
  return resolveIdentifierToString(node, checker, sourceFile);
337
358
  }
338
-
359
+
339
360
  return null;
340
361
  }
341
362
 
@@ -438,39 +459,39 @@ function resolveIdentifierToString(node, checker, sourceFile) {
438
459
  if (!symbol) {
439
460
  return null;
440
461
  }
441
-
462
+
442
463
  // First try to resolve through value declaration
443
464
  if (symbol.valueDeclaration) {
444
465
  const declaration = symbol.valueDeclaration;
445
-
466
+
446
467
  // Handle variable declarations with string literal initializers
447
- if (ts.isVariableDeclaration(declaration) &&
468
+ if (ts.isVariableDeclaration(declaration) &&
448
469
  declaration.initializer &&
449
470
  ts.isStringLiteral(declaration.initializer)) {
450
471
  return declaration.initializer.text;
451
472
  }
452
-
473
+
453
474
  // Handle const declarations with object literals containing string properties
454
- if (ts.isVariableDeclaration(declaration) &&
475
+ if (ts.isVariableDeclaration(declaration) &&
455
476
  declaration.initializer &&
456
477
  ts.isObjectLiteralExpression(declaration.initializer)) {
457
478
  // This case is handled by property access resolution
458
479
  return null;
459
480
  }
460
481
  }
461
-
482
+
462
483
  // If value declaration doesn't exist or doesn't help, try type resolution
463
484
  // This handles imported constants that are resolved through TypeScript's type system
464
485
  const type = checker.getTypeOfSymbolAtLocation(symbol, node);
465
486
  if (type && type.isStringLiteral && typeof type.isStringLiteral === 'function' && type.isStringLiteral()) {
466
487
  return type.value;
467
488
  }
468
-
489
+
469
490
  // Alternative approach for string literal types (different TypeScript versions)
470
491
  if (type && type.flags && (type.flags & ts.TypeFlags.StringLiteral)) {
471
492
  return type.value;
472
493
  }
473
-
494
+
474
495
  return null;
475
496
  } catch (error) {
476
497
  return null;
@@ -485,7 +506,7 @@ function resolveIdentifierToString(node, checker, sourceFile) {
485
506
  */
486
507
  function findPropertyByKey(objectNode, key) {
487
508
  if (!objectNode.properties) return null;
488
-
509
+
489
510
  return objectNode.properties.find(prop => {
490
511
  if (prop.name) {
491
512
  if (ts.isIdentifier(prop.name)) {
@@ -506,30 +527,37 @@ function findPropertyByKey(objectNode, key) {
506
527
  */
507
528
  function cleanupProperties(properties) {
508
529
  const cleaned = {};
509
-
530
+
510
531
  for (const [key, value] of Object.entries(properties)) {
511
532
  if (value && typeof value === 'object') {
512
- // Remove __unresolved marker
533
+ // Remove __unresolved marker from the value itself
513
534
  if (value.__unresolved) {
514
535
  delete value.__unresolved;
515
536
  }
516
-
537
+
517
538
  // Recursively clean nested properties
518
539
  if (value.properties) {
519
540
  value.properties = cleanupProperties(value.properties);
520
541
  }
521
-
522
- // Clean array item properties
523
- if (value.type === 'array' && value.items && value.items.properties) {
524
- value.items.properties = cleanupProperties(value.items.properties);
542
+
543
+ // Clean array item properties and __unresolved markers
544
+ if (value.type === 'array' && value.items) {
545
+ // Remove __unresolved from items directly
546
+ if (value.items.__unresolved) {
547
+ delete value.items.__unresolved;
548
+ }
549
+ // Clean nested properties in items
550
+ if (value.items.properties) {
551
+ value.items.properties = cleanupProperties(value.items.properties);
552
+ }
525
553
  }
526
-
554
+
527
555
  cleaned[key] = value;
528
556
  } else {
529
557
  cleaned[key] = value;
530
558
  }
531
559
  }
532
-
560
+
533
561
  return cleaned;
534
562
  }
535
563