@api-client/core 0.18.13 → 0.18.15

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 (25) hide show
  1. package/build/src/modeling/DomainValidation.d.ts +78 -0
  2. package/build/src/modeling/DomainValidation.d.ts.map +1 -1
  3. package/build/src/modeling/DomainValidation.js +78 -0
  4. package/build/src/modeling/DomainValidation.js.map +1 -1
  5. package/build/src/modeling/importers/FilteringJsonSchemaImporter.d.ts +84 -0
  6. package/build/src/modeling/importers/FilteringJsonSchemaImporter.d.ts.map +1 -0
  7. package/build/src/modeling/importers/FilteringJsonSchemaImporter.js +254 -0
  8. package/build/src/modeling/importers/FilteringJsonSchemaImporter.js.map +1 -0
  9. package/build/src/modeling/importers/SchemaFilteringStrategy.d.ts +72 -0
  10. package/build/src/modeling/importers/SchemaFilteringStrategy.d.ts.map +1 -0
  11. package/build/src/modeling/importers/SchemaFilteringStrategy.js +140 -0
  12. package/build/src/modeling/importers/SchemaFilteringStrategy.js.map +1 -0
  13. package/build/src/modeling/validation/entity_validation.d.ts +17 -3
  14. package/build/src/modeling/validation/entity_validation.d.ts.map +1 -1
  15. package/build/src/modeling/validation/entity_validation.js +51 -10
  16. package/build/src/modeling/validation/entity_validation.js.map +1 -1
  17. package/build/tsconfig.tsbuildinfo +1 -1
  18. package/data/models/example-generator-api.json +15 -15
  19. package/package.json +1 -1
  20. package/src/modeling/DomainValidation.ts +78 -0
  21. package/src/modeling/importers/FilteringJsonSchemaImporter.ts +324 -0
  22. package/src/modeling/importers/SchemaFilteringStrategy.ts +201 -0
  23. package/src/modeling/validation/entity_validation.ts +59 -10
  24. package/tests/unit/modeling/importers/schema_filtering.spec.ts +764 -0
  25. package/tests/unit/modeling/validation/entity_validation.spec.ts +95 -0
@@ -42065,10 +42065,10 @@
42065
42065
  "@id": "#191"
42066
42066
  },
42067
42067
  {
42068
- "@id": "#197"
42068
+ "@id": "#194"
42069
42069
  },
42070
42070
  {
42071
- "@id": "#194"
42071
+ "@id": "#197"
42072
42072
  },
42073
42073
  {
42074
42074
  "@id": "#200"
@@ -42813,13 +42813,13 @@
42813
42813
  "@id": "#210"
42814
42814
  },
42815
42815
  {
42816
- "@id": "#213"
42816
+ "@id": "#219"
42817
42817
  },
42818
42818
  {
42819
- "@id": "#216"
42819
+ "@id": "#213"
42820
42820
  },
42821
42821
  {
42822
- "@id": "#219"
42822
+ "@id": "#216"
42823
42823
  }
42824
42824
  ],
42825
42825
  "doc:root": false,
@@ -43457,7 +43457,7 @@
43457
43457
  "doc:ExternalDomainElement",
43458
43458
  "doc:DomainElement"
43459
43459
  ],
43460
- "doc:raw": "code: '5'\ndescription: 'Limited company'\n",
43460
+ "doc:raw": "addressType: 'REGISTERED-OFFICE-ADDRESS'\nstreetName: 'UITBREIDINGSTRAAT'\nhouseNumber: '84'\nhouseNumberAddition: '/1'\npostalCode: '2600'\ncity: 'BERCHEM (ANTWERPEN)'\ncountry: 'Belgium'\ncountryCode: 'BE'\nfullFormatedAddress: \"UITBREIDINGSTRAAT 84 /1, 2600 BERCHEM (ANTWERPEN), BELIUM\"\n",
43461
43461
  "core:mediaType": "application/yaml",
43462
43462
  "sourcemaps:sources": [
43463
43463
  {
@@ -43478,7 +43478,7 @@
43478
43478
  "doc:ExternalDomainElement",
43479
43479
  "doc:DomainElement"
43480
43480
  ],
43481
- "doc:raw": "addressType: 'REGISTERED-OFFICE-ADDRESS'\nstreetName: 'UITBREIDINGSTRAAT'\nhouseNumber: '84'\nhouseNumberAddition: '/1'\npostalCode: '2600'\ncity: 'BERCHEM (ANTWERPEN)'\ncountry: 'Belgium'\ncountryCode: 'BE'\nfullFormatedAddress: \"UITBREIDINGSTRAAT 84 /1, 2600 BERCHEM (ANTWERPEN), BELIUM\"\n",
43481
+ "doc:raw": "code: '5'\ndescription: 'Limited company'\n",
43482
43482
  "core:mediaType": "application/yaml",
43483
43483
  "sourcemaps:sources": [
43484
43484
  {
@@ -44253,7 +44253,7 @@
44253
44253
  "doc:ExternalDomainElement",
44254
44254
  "doc:DomainElement"
44255
44255
  ],
44256
- "doc:raw": "type: 'GENERAL'\ncountryDialCode : '+32'\nareaCode : '21'\nsubscriberNumber: '12.87.00'\nformatted: '+32-(0)21 302099'\n",
44256
+ "doc:raw": "-\n type: 'GENERAL'\n value: 'info@company.be'\n-\n type: 'IT_DEPT'\n value: 'it-service@company.be'\n",
44257
44257
  "core:mediaType": "application/yaml",
44258
44258
  "sourcemaps:sources": [
44259
44259
  {
@@ -44274,7 +44274,7 @@
44274
44274
  "doc:ExternalDomainElement",
44275
44275
  "doc:DomainElement"
44276
44276
  ],
44277
- "doc:raw": "-\n type: 'GENERAL'\n value: 'info@company.be'\n-\n type: 'IT_DEPT'\n value: 'it-service@company.be'\n",
44277
+ "doc:raw": "type: \"GENERAL\"\nvalue: \"www.company.be\"\n",
44278
44278
  "core:mediaType": "application/yaml",
44279
44279
  "sourcemaps:sources": [
44280
44280
  {
@@ -44295,7 +44295,7 @@
44295
44295
  "doc:ExternalDomainElement",
44296
44296
  "doc:DomainElement"
44297
44297
  ],
44298
- "doc:raw": "type: \"GENERAL\"\nvalue: \"www.company.be\"\n",
44298
+ "doc:raw": "type: 'GENERAL'\ncountryDialCode : '+32'\nareaCode : '21'\nsubscriberNumber: '12.87.00'\nformatted: '+32-(0)21 302099'\n",
44299
44299
  "core:mediaType": "application/yaml",
44300
44300
  "sourcemaps:sources": [
44301
44301
  {
@@ -44761,12 +44761,12 @@
44761
44761
  {
44762
44762
  "@id": "#196/source-map/lexical/element_0",
44763
44763
  "sourcemaps:element": "amf://id#196",
44764
- "sourcemaps:value": "[(1,0)-(3,0)]"
44764
+ "sourcemaps:value": "[(1,0)-(10,0)]"
44765
44765
  },
44766
44766
  {
44767
44767
  "@id": "#199/source-map/lexical/element_0",
44768
44768
  "sourcemaps:element": "amf://id#199",
44769
- "sourcemaps:value": "[(1,0)-(10,0)]"
44769
+ "sourcemaps:value": "[(1,0)-(3,0)]"
44770
44770
  },
44771
44771
  {
44772
44772
  "@id": "#202/source-map/lexical/element_0",
@@ -45121,17 +45121,17 @@
45121
45121
  {
45122
45122
  "@id": "#215/source-map/lexical/element_0",
45123
45123
  "sourcemaps:element": "amf://id#215",
45124
- "sourcemaps:value": "[(1,0)-(6,0)]"
45124
+ "sourcemaps:value": "[(1,0)-(7,0)]"
45125
45125
  },
45126
45126
  {
45127
45127
  "@id": "#218/source-map/lexical/element_0",
45128
45128
  "sourcemaps:element": "amf://id#218",
45129
- "sourcemaps:value": "[(1,0)-(7,0)]"
45129
+ "sourcemaps:value": "[(1,0)-(3,0)]"
45130
45130
  },
45131
45131
  {
45132
45132
  "@id": "#221/source-map/lexical/element_0",
45133
45133
  "sourcemaps:element": "amf://id#221",
45134
- "sourcemaps:value": "[(1,0)-(3,0)]"
45134
+ "sourcemaps:value": "[(1,0)-(6,0)]"
45135
45135
  },
45136
45136
  {
45137
45137
  "@id": "#338/source-map/synthesized-field/element_1",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@api-client/core",
3
3
  "description": "The API Client's core client library. Works in NodeJS and in a ES enabled browser.",
4
- "version": "0.18.13",
4
+ "version": "0.18.15",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -6,6 +6,75 @@ import { EntityValidation } from './validation/entity_validation.js'
6
6
  import { PropertyValidation } from './validation/property_validation.js'
7
7
  import { SemanticValidation } from './validation/semantic_validation.js'
8
8
 
9
+ /**
10
+ * DomainValidation performs comprehensive validation on a data domain and its components.
11
+ *
12
+ * This class orchestrates validation across all domain elements including entities, properties,
13
+ * associations, and semantics. It ensures that the data domain is well-formed and follows
14
+ * established conventions before it can be published or used in API generation.
15
+ *
16
+ * ## Validation Scope
17
+ *
18
+ * The validation process covers:
19
+ * - **Entities**: Structure, naming, primary keys, and inheritance
20
+ * - **Properties**: Naming conventions, data types, and constraints
21
+ * - **Associations**: Relationships, targets, and naming
22
+ * - **Semantics**: Recommended patterns and best practices
23
+ *
24
+ * ## Validation Severity Levels
25
+ *
26
+ * - **Error**: Blocking issues that prevent domain publication
27
+ * - **Warning**: Issues that may cause problems but don't block publication
28
+ * - **Info**: Recommendations for best practices
29
+ *
30
+ * ## Usage
31
+ *
32
+ * ```typescript
33
+ * const domain = new DataDomain()
34
+ * // ... add entities, properties, associations
35
+ *
36
+ * const validator = new DomainValidation(domain)
37
+ * const report = validator.validate()
38
+ *
39
+ * if (report.canProceed) {
40
+ * // Domain is valid and can be published
41
+ * } else {
42
+ * // Handle validation errors
43
+ * console.log(report.impact)
44
+ * }
45
+ * ```
46
+ *
47
+ * ## Validation Rules
48
+ *
49
+ * ### Entity Validation Rules
50
+ * - **Primary Key**: Each entity must have a primary key (can be inherited)
51
+ * - **Minimum Properties**: Entities should have properties or associations (can be inherited)
52
+ * - **Naming**: Entity names must follow PostgreSQL naming conventions
53
+ * - **Uniqueness**: Entity names must be unique within the domain
54
+ *
55
+ * ### Property Validation Rules
56
+ * - **Naming**: Properties must follow PostgreSQL column naming conventions
57
+ * - **Length**: Names must be 2-59 characters long
58
+ * - **Format**: Names must start with letter/underscore, contain only alphanumeric/underscore
59
+ * - **Reserved Words**: Names cannot be PostgreSQL reserved keywords
60
+ * - **Case**: Snake case is recommended (lowercase with underscores)
61
+ *
62
+ * ### Association Validation Rules
63
+ * - **Targets**: Associations must have at least one target entity
64
+ * - **Target Existence**: Target entities must exist in the domain
65
+ * - **Naming**: Same rules as properties
66
+ *
67
+ * ### Semantic Validation Rules
68
+ * - **User Entity**: Recommended to have at least one entity with User semantic
69
+ * - **Timestamps**: Recommended to have CreatedTimestamp and UpdatedTimestamp properties
70
+ * - **Soft Delete**: Recommended to have soft delete capability for entities
71
+ * - **Data Types**: Semantic-specific data type validation
72
+ *
73
+ * @see {@link EntityValidation} for detailed entity validation rules
74
+ * @see {@link PropertyValidation} for detailed property validation rules
75
+ * @see {@link AssociationValidation} for detailed association validation rules
76
+ * @see {@link SemanticValidation} for detailed semantic validation rules
77
+ */
9
78
  export class DomainValidation {
10
79
  private root: DataDomain
11
80
 
@@ -13,6 +82,15 @@ export class DomainValidation {
13
82
  this.root = root
14
83
  }
15
84
 
85
+ /**
86
+ * Performs comprehensive validation on the entire data domain.
87
+ *
88
+ * This method validates all entities, properties, associations, and semantics
89
+ * in the domain. It returns a detailed report of all validation issues found,
90
+ * categorized by severity level.
91
+ *
92
+ * @returns A comprehensive validation report with all issues found
93
+ */
16
94
  validate(): DomainImpactReport {
17
95
  const result: DomainImpactReport = {
18
96
  key: '',
@@ -0,0 +1,324 @@
1
+ import type { JSONSchema7 } from 'json-schema'
2
+ import type { JsonImportReport, InMemorySchema } from './JsonSchemaImporter.js'
3
+ import { JsonSchemaImporter } from './JsonSchemaImporter.js'
4
+ import {
5
+ SchemaFilteringStrategy,
6
+ type SchemaFilteringOptions,
7
+ FILTERING_STRATEGIES,
8
+ } from './SchemaFilteringStrategy.js'
9
+ import type { DataDomain } from '../DataDomain.js'
10
+ import type { DomainEntity } from '../DomainEntity.js'
11
+ import type { ImportMessage } from './JsonSchemaImporter.js'
12
+
13
+ /**
14
+ * Extended JsonSchemaImporter that supports filtering out non-structural schemas.
15
+ * This version can eliminate schemas that don't contribute meaningful structure
16
+ * to the data model, such as empty objects or conceptual marker types.
17
+ */
18
+ export class FilteringJsonSchemaImporter extends JsonSchemaImporter {
19
+ private filteringStrategy: SchemaFilteringStrategy
20
+
21
+ constructor(domain: DataDomain, filteringOptions: SchemaFilteringOptions = {}) {
22
+ super(domain)
23
+ this.filteringStrategy = new SchemaFilteringStrategy(filteringOptions)
24
+ }
25
+
26
+ /**
27
+ * Creates an importer with a predefined filtering strategy.
28
+ */
29
+ static withStrategy(
30
+ domain: DataDomain,
31
+ strategyName: keyof typeof FILTERING_STRATEGIES
32
+ ): FilteringJsonSchemaImporter {
33
+ const strategy = FILTERING_STRATEGIES[strategyName]
34
+ // Access private options through a public method (would need to be added to SchemaFilteringStrategy)
35
+ const options = strategy['options'] as SchemaFilteringOptions
36
+ return new FilteringJsonSchemaImporter(domain, options)
37
+ }
38
+
39
+ /**
40
+ * Creates an importer optimized for API development.
41
+ * Excludes schemas that don't contribute to API structure.
42
+ */
43
+ static forApiModeling(domain: DataDomain): FilteringJsonSchemaImporter {
44
+ return new FilteringJsonSchemaImporter(domain, {
45
+ excludeEmptyObjects: true,
46
+ excludeConceptualMarkers: true,
47
+ excludePatterns: [
48
+ /.*Enumeration$/, // Exclude *Enumeration schemas
49
+ /.*Type$/, // Exclude conceptual *Type schemas that are just markers
50
+ ],
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Creates an importer optimized for database schema generation.
56
+ * Very strict filtering - only includes schemas with concrete properties.
57
+ */
58
+ static forDatabaseModeling(domain: DataDomain): FilteringJsonSchemaImporter {
59
+ return new FilteringJsonSchemaImporter(domain, {
60
+ excludeEmptyObjects: true,
61
+ excludeConceptualMarkers: true,
62
+ customExclusionFilter: (schema: JSONSchema7) => {
63
+ // For database modeling, exclude anything that doesn't have properties or clear structure
64
+ return (
65
+ schema.type === 'object' &&
66
+ !schema.properties &&
67
+ !schema.additionalProperties &&
68
+ !schema.allOf?.some(
69
+ (subSchema) => typeof subSchema === 'object' && 'properties' in subSchema && subSchema.properties
70
+ )
71
+ )
72
+ },
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Override the base import method to apply filtering.
78
+ */
79
+ override async import(schemas: InMemorySchema[], modelName: string): Promise<JsonImportReport> {
80
+ // Apply pre-filtering to the schemas before import
81
+ const filteredSchemas = this.preFilterSchemas(schemas)
82
+
83
+ // Get the raw import result with filtered schemas
84
+ const rawResult = await super.import(filteredSchemas.schemas, modelName)
85
+
86
+ // Apply post-filtering to clean up any remaining issues
87
+ const finalReport = this.postFilterReport(rawResult, filteredSchemas.messages)
88
+
89
+ return finalReport
90
+ }
91
+
92
+ /**
93
+ * Pre-filters schemas before they are processed by the base importer.
94
+ */
95
+ private preFilterSchemas(schemas: InMemorySchema[]): {
96
+ schemas: InMemorySchema[]
97
+ messages: ImportMessage[]
98
+ } {
99
+ const filteredSchemas: InMemorySchema[] = []
100
+ const messages: ImportMessage[] = []
101
+
102
+ for (const schema of schemas) {
103
+ const schemaId = schema.contents.$id || schema.path
104
+
105
+ if (this.filteringStrategy.shouldExcludeSchema(schema.contents, schemaId)) {
106
+ const reason = this.filteringStrategy.getExclusionReason(schema.contents, schemaId)
107
+ messages.push({
108
+ level: 'info',
109
+ message: `Pre-filtered schema '${schemaId}': ${reason}`,
110
+ location: schema.path,
111
+ })
112
+ } else {
113
+ filteredSchemas.push(schema)
114
+ }
115
+ }
116
+
117
+ if (messages.length > 0) {
118
+ messages.push({
119
+ level: 'info',
120
+ message: `Pre-filtering removed ${messages.length} non-structural schema(s)`,
121
+ })
122
+ }
123
+
124
+ return { schemas: filteredSchemas, messages }
125
+ }
126
+
127
+ /**
128
+ * Post-processes the import report to clean up any remaining filtered entities.
129
+ */
130
+ private postFilterReport(report: JsonImportReport, preFilterMessages: ImportMessage[]): JsonImportReport {
131
+ const { model, messages } = report
132
+ const allMessages = [...preFilterMessages, ...messages]
133
+ let removedCount = 0
134
+
135
+ // Get entities as array for easier processing
136
+ const entities = Array.from(model.listEntities())
137
+ const entitiesToRemove: string[] = []
138
+
139
+ // Check each entity to see if it should be filtered out
140
+ for (const entity of entities) {
141
+ const shouldFilter = this.shouldFilterEntity(entity)
142
+
143
+ if (shouldFilter) {
144
+ entitiesToRemove.push(entity.key)
145
+ const reason = this.getEntityFilterReason(entity)
146
+ allMessages.push({
147
+ level: 'info',
148
+ message: `Post-filtered entity '${entity.info.name}': ${reason}`,
149
+ location: entity.key,
150
+ })
151
+ removedCount++
152
+ }
153
+ }
154
+
155
+ // Remove the filtered entities
156
+ for (const entityKey of entitiesToRemove) {
157
+ try {
158
+ model.removeEntity(entityKey)
159
+ } catch (error) {
160
+ allMessages.push({
161
+ level: 'warning',
162
+ message: `Failed to remove entity '${entityKey}': ${error}`,
163
+ location: entityKey,
164
+ })
165
+ }
166
+ }
167
+
168
+ // Clean up orphaned associations
169
+ this.cleanupOrphanedAssociations(entities, entitiesToRemove, allMessages)
170
+
171
+ if (removedCount > 0) {
172
+ allMessages.push({
173
+ level: 'info',
174
+ message: `Post-filtering removed ${removedCount} additional non-structural entity/entities`,
175
+ })
176
+ }
177
+
178
+ return {
179
+ model,
180
+ messages: allMessages,
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Determines if an entity should be filtered out based on its characteristics.
186
+ */
187
+ private shouldFilterEntity(entity: DomainEntity): boolean {
188
+ // Check if entity has no properties and no meaningful associations
189
+ const hasProperties = Array.from(entity.listProperties()).length > 0
190
+ const hasAssociations = Array.from(entity.listAssociations()).length > 0
191
+
192
+ // If it has neither properties nor associations, it's likely a conceptual marker
193
+ if (!hasProperties && !hasAssociations) {
194
+ return this.filteringStrategy.shouldExcludeSchema(
195
+ {
196
+ type: 'object',
197
+ title: entity.info.name,
198
+ description: entity.info.description,
199
+ } as JSONSchema7,
200
+ entity.info.name
201
+ )
202
+ }
203
+
204
+ return false
205
+ }
206
+
207
+ /**
208
+ * Gets a human-readable reason for filtering an entity.
209
+ */
210
+ private getEntityFilterReason(entity: DomainEntity): string {
211
+ const hasProperties = Array.from(entity.listProperties()).length > 0
212
+ const hasAssociations = Array.from(entity.listAssociations()).length > 0
213
+
214
+ if (!hasProperties && !hasAssociations) {
215
+ return 'Entity has no properties or associations (likely a conceptual marker)'
216
+ }
217
+
218
+ return 'Entity matches exclusion pattern'
219
+ }
220
+
221
+ /**
222
+ * Removes associations that reference entities that were filtered out.
223
+ */
224
+ private cleanupOrphanedAssociations(
225
+ entities: DomainEntity[],
226
+ removedEntityKeys: string[],
227
+ messages: ImportMessage[]
228
+ ): void {
229
+ for (const entity of entities) {
230
+ const associationsToRemove: string[] = []
231
+
232
+ for (const association of entity.listAssociations()) {
233
+ const hasOrphanedTargets = association.targets?.some((target) => removedEntityKeys.includes(target.key))
234
+
235
+ if (hasOrphanedTargets) {
236
+ associationsToRemove.push(association.key)
237
+ messages.push({
238
+ level: 'info',
239
+ message: `Removed association '${association.info.name}' from '${entity.info.name}' due to filtered target entity`,
240
+ location: `${entity.key}.${association.key}`,
241
+ })
242
+ }
243
+ }
244
+
245
+ for (const associationKey of associationsToRemove) {
246
+ try {
247
+ entity.removeAssociation(associationKey)
248
+ } catch (error) {
249
+ messages.push({
250
+ level: 'warning',
251
+ message: `Failed to remove association '${associationKey}' from '${entity.info.name}': ${error}`,
252
+ location: `${entity.key}.${associationKey}`,
253
+ })
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Utility functions for working with filtered imports.
262
+ */
263
+ export const SchemaFilteringUtils = {
264
+ /**
265
+ * Analyzes a JSON schema to determine if it would be filtered by the given strategy.
266
+ */
267
+ wouldBeFiltered(schema: JSONSchema7, strategy: SchemaFilteringStrategy): boolean {
268
+ return strategy.shouldExcludeSchema(schema)
269
+ },
270
+
271
+ /**
272
+ * Gets statistics about what would be filtered from a set of schemas.
273
+ */
274
+ getFilteringStats(
275
+ schemas: { id: string; schema: JSONSchema7 }[],
276
+ strategy: SchemaFilteringStrategy
277
+ ): {
278
+ total: number
279
+ filtered: number
280
+ remaining: number
281
+ filteredSchemas: { id: string; reason: string }[]
282
+ } {
283
+ const filteredSchemas: { id: string; reason: string }[] = []
284
+
285
+ for (const { id, schema } of schemas) {
286
+ if (strategy.shouldExcludeSchema(schema, id)) {
287
+ filteredSchemas.push({
288
+ id,
289
+ reason: strategy.getExclusionReason(schema, id),
290
+ })
291
+ }
292
+ }
293
+
294
+ return {
295
+ total: schemas.length,
296
+ filtered: filteredSchemas.length,
297
+ remaining: schemas.length - filteredSchemas.length,
298
+ filteredSchemas,
299
+ }
300
+ },
301
+
302
+ /**
303
+ * Creates a report showing what would be filtered from a schema collection.
304
+ */
305
+ createFilteringReport(schemas: { id: string; schema: JSONSchema7 }[], strategy: SchemaFilteringStrategy): string {
306
+ const stats = this.getFilteringStats(schemas, strategy)
307
+
308
+ let report = `Schema Filtering Report\n`
309
+ report += `======================\n\n`
310
+ report += `Total schemas: ${stats.total}\n`
311
+ report += `Would be filtered: ${stats.filtered}\n`
312
+ report += `Would remain: ${stats.remaining}\n\n`
313
+
314
+ if (stats.filteredSchemas.length > 0) {
315
+ report += `Schemas that would be filtered:\n`
316
+ report += `-------------------------------\n`
317
+ for (const { id, reason } of stats.filteredSchemas) {
318
+ report += `• ${id}: ${reason}\n`
319
+ }
320
+ }
321
+
322
+ return report
323
+ },
324
+ }