@living-architecture/riviere-builder 0.2.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.
@@ -0,0 +1,749 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { RiviereQuery } from '@living-architecture/riviere-query';
4
+ import { calculateStats, findOrphans, findWarnings, toRiviereGraph, validateGraph } from './inspection';
5
+ import { assertCustomTypeExists, assertDomainExists, assertRequiredPropertiesProvided } from './builder-assertions';
6
+ import { ComponentId } from '@living-architecture/riviere-schema';
7
+ import { createSourceNotFoundError, findNearMatches } from './component-suggestion';
8
+ import { DuplicateComponentError, DuplicateDomainError, InvalidEnrichmentTargetError } from './errors';
9
+ /**
10
+ * Programmatically construct Rivière architecture graphs.
11
+ *
12
+ * RiviereBuilder provides a fluent API for creating graphs, adding components,
13
+ * linking them together, and exporting valid JSON conforming to the Rivière schema.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { RiviereBuilder } from '@living-architecture/riviere-builder'
18
+ *
19
+ * const builder = RiviereBuilder.new({
20
+ * sources: [{ type: 'git', url: 'https://github.com/org/repo' }],
21
+ * domains: { orders: { description: 'Order management' } }
22
+ * })
23
+ *
24
+ * const api = builder.addApi({
25
+ * name: 'Create Order',
26
+ * domain: 'orders',
27
+ * module: 'checkout',
28
+ * apiType: 'REST',
29
+ * sourceLocation: { file: 'src/api/orders.ts', line: 10 }
30
+ * })
31
+ *
32
+ * const graph = builder.build()
33
+ * ```
34
+ */
35
+ export class RiviereBuilder {
36
+ graph;
37
+ constructor(graph) {
38
+ this.graph = graph;
39
+ }
40
+ /**
41
+ * Restores a builder from a previously serialized graph.
42
+ *
43
+ * Use this to continue building a graph that was saved mid-construction,
44
+ * or to modify an existing graph.
45
+ *
46
+ * @param graph - A valid RiviereGraph object to resume from
47
+ * @returns A new RiviereBuilder instance with the graph state restored
48
+ * @throws If the graph is missing required sources
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * const json = await fs.readFile('draft.json', 'utf-8')
53
+ * const graph = JSON.parse(json)
54
+ * const builder = RiviereBuilder.resume(graph)
55
+ * builder.addApi({ ... })
56
+ * ```
57
+ */
58
+ static resume(graph) {
59
+ if (!graph.metadata.sources || graph.metadata.sources.length === 0) {
60
+ throw new Error('Invalid graph: missing sources');
61
+ }
62
+ const builderGraph = {
63
+ version: graph.version,
64
+ metadata: {
65
+ ...graph.metadata,
66
+ sources: graph.metadata.sources,
67
+ customTypes: graph.metadata.customTypes ?? {},
68
+ },
69
+ components: graph.components,
70
+ links: graph.links,
71
+ externalLinks: graph.externalLinks ?? [],
72
+ };
73
+ return new RiviereBuilder(builderGraph);
74
+ }
75
+ /**
76
+ * Creates a new builder with initial configuration.
77
+ *
78
+ * @param options - Configuration including sources and domains
79
+ * @returns A new RiviereBuilder instance
80
+ * @throws If sources array is empty
81
+ * @throws If domains object is empty
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const builder = RiviereBuilder.new({
86
+ * name: 'My System',
87
+ * sources: [{ type: 'git', url: 'https://github.com/org/repo' }],
88
+ * domains: {
89
+ * orders: { description: 'Order management' },
90
+ * users: { description: 'User accounts' }
91
+ * }
92
+ * })
93
+ * ```
94
+ */
95
+ static new(options) {
96
+ if (options.sources.length === 0) {
97
+ throw new Error('At least one source required');
98
+ }
99
+ if (Object.keys(options.domains).length === 0) {
100
+ throw new Error('At least one domain required');
101
+ }
102
+ const graph = {
103
+ version: '1.0',
104
+ metadata: {
105
+ ...(options.name !== undefined && { name: options.name }),
106
+ ...(options.description !== undefined && { description: options.description }),
107
+ sources: options.sources,
108
+ domains: options.domains,
109
+ customTypes: {},
110
+ },
111
+ components: [],
112
+ links: [],
113
+ externalLinks: [],
114
+ };
115
+ return new RiviereBuilder(graph);
116
+ }
117
+ /**
118
+ * Adds an additional source repository to the graph.
119
+ *
120
+ * @param source - Source repository information
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * builder.addSource({
125
+ * type: 'git',
126
+ * url: 'https://github.com/org/another-repo'
127
+ * })
128
+ * ```
129
+ */
130
+ addSource(source) {
131
+ this.graph.metadata.sources.push(source);
132
+ }
133
+ /**
134
+ * Adds a new domain to the graph.
135
+ *
136
+ * @param input - Domain name and description
137
+ * @throws If domain with same name already exists
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * builder.addDomain({
142
+ * name: 'payments',
143
+ * description: 'Payment processing'
144
+ * })
145
+ * ```
146
+ */
147
+ addDomain(input) {
148
+ if (this.graph.metadata.domains[input.name]) {
149
+ throw new DuplicateDomainError(input.name);
150
+ }
151
+ this.graph.metadata.domains[input.name] = {
152
+ description: input.description,
153
+ systemType: input.systemType,
154
+ };
155
+ }
156
+ /**
157
+ * Adds a UI component to the graph.
158
+ *
159
+ * @param input - UI component properties including route and source location
160
+ * @returns The created UI component with generated ID
161
+ * @throws If the specified domain does not exist
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * const ui = builder.addUI({
166
+ * name: 'Order List',
167
+ * domain: 'orders',
168
+ * module: 'dashboard',
169
+ * route: '/orders',
170
+ * sourceLocation: { file: 'src/pages/OrderList.tsx', line: 15 }
171
+ * })
172
+ * ```
173
+ */
174
+ addUI(input) {
175
+ this.validateDomainExists(input.domain);
176
+ const id = this.generateComponentId(input.domain, input.module, 'ui', input.name);
177
+ const component = {
178
+ id,
179
+ type: 'UI',
180
+ name: input.name,
181
+ domain: input.domain,
182
+ module: input.module,
183
+ route: input.route,
184
+ sourceLocation: input.sourceLocation,
185
+ ...(input.description !== undefined && { description: input.description }),
186
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
187
+ };
188
+ return this.registerComponent(component);
189
+ }
190
+ /**
191
+ * Adds an API component to the graph.
192
+ *
193
+ * @param input - API component properties including type, method, and path
194
+ * @returns The created API component with generated ID
195
+ * @throws If the specified domain does not exist
196
+ *
197
+ * @example
198
+ * ```typescript
199
+ * const api = builder.addApi({
200
+ * name: 'Create Order',
201
+ * domain: 'orders',
202
+ * module: 'checkout',
203
+ * apiType: 'REST',
204
+ * httpMethod: 'POST',
205
+ * path: '/api/orders',
206
+ * sourceLocation: { file: 'src/api/orders.ts', line: 25 }
207
+ * })
208
+ * ```
209
+ */
210
+ addApi(input) {
211
+ this.validateDomainExists(input.domain);
212
+ const id = this.generateComponentId(input.domain, input.module, 'api', input.name);
213
+ const component = {
214
+ id,
215
+ type: 'API',
216
+ name: input.name,
217
+ domain: input.domain,
218
+ module: input.module,
219
+ apiType: input.apiType,
220
+ sourceLocation: input.sourceLocation,
221
+ ...(input.httpMethod !== undefined && { httpMethod: input.httpMethod }),
222
+ ...(input.path !== undefined && { path: input.path }),
223
+ ...(input.operationName !== undefined && { operationName: input.operationName }),
224
+ ...(input.description !== undefined && { description: input.description }),
225
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
226
+ };
227
+ return this.registerComponent(component);
228
+ }
229
+ /**
230
+ * Adds a UseCase component to the graph.
231
+ *
232
+ * @param input - UseCase component properties
233
+ * @returns The created UseCase component with generated ID
234
+ * @throws If the specified domain does not exist
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const useCase = builder.addUseCase({
239
+ * name: 'Place Order',
240
+ * domain: 'orders',
241
+ * module: 'checkout',
242
+ * sourceLocation: { file: 'src/usecases/PlaceOrder.ts', line: 10 }
243
+ * })
244
+ * ```
245
+ */
246
+ addUseCase(input) {
247
+ this.validateDomainExists(input.domain);
248
+ const id = this.generateComponentId(input.domain, input.module, 'usecase', input.name);
249
+ const component = {
250
+ id,
251
+ type: 'UseCase',
252
+ name: input.name,
253
+ domain: input.domain,
254
+ module: input.module,
255
+ sourceLocation: input.sourceLocation,
256
+ ...(input.description !== undefined && { description: input.description }),
257
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
258
+ };
259
+ return this.registerComponent(component);
260
+ }
261
+ /**
262
+ * Adds a DomainOp component to the graph.
263
+ *
264
+ * DomainOp represents domain operations that change entity state.
265
+ * Can be enriched later with state changes and business rules.
266
+ *
267
+ * @param input - DomainOp component properties including operation name
268
+ * @returns The created DomainOp component with generated ID
269
+ * @throws If the specified domain does not exist
270
+ *
271
+ * @example
272
+ * ```typescript
273
+ * const domainOp = builder.addDomainOp({
274
+ * name: 'Confirm Order',
275
+ * domain: 'orders',
276
+ * module: 'fulfillment',
277
+ * operationName: 'confirmOrder',
278
+ * entity: 'Order',
279
+ * sourceLocation: { file: 'src/domain/Order.ts', line: 45 }
280
+ * })
281
+ * ```
282
+ */
283
+ addDomainOp(input) {
284
+ this.validateDomainExists(input.domain);
285
+ const id = this.generateComponentId(input.domain, input.module, 'domainop', input.name);
286
+ const component = {
287
+ id,
288
+ type: 'DomainOp',
289
+ name: input.name,
290
+ domain: input.domain,
291
+ module: input.module,
292
+ operationName: input.operationName,
293
+ sourceLocation: input.sourceLocation,
294
+ ...(input.entity !== undefined && { entity: input.entity }),
295
+ ...(input.signature !== undefined && { signature: input.signature }),
296
+ ...(input.behavior !== undefined && { behavior: input.behavior }),
297
+ ...(input.stateChanges !== undefined && { stateChanges: input.stateChanges }),
298
+ ...(input.businessRules !== undefined && { businessRules: input.businessRules }),
299
+ ...(input.description !== undefined && { description: input.description }),
300
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
301
+ };
302
+ return this.registerComponent(component);
303
+ }
304
+ /**
305
+ * Adds an Event component to the graph.
306
+ *
307
+ * @param input - Event component properties including event name
308
+ * @returns The created Event component with generated ID
309
+ * @throws If the specified domain does not exist
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * const event = builder.addEvent({
314
+ * name: 'Order Placed',
315
+ * domain: 'orders',
316
+ * module: 'checkout',
317
+ * eventName: 'OrderPlaced',
318
+ * sourceLocation: { file: 'src/events/OrderPlaced.ts', line: 5 }
319
+ * })
320
+ * ```
321
+ */
322
+ addEvent(input) {
323
+ this.validateDomainExists(input.domain);
324
+ const id = this.generateComponentId(input.domain, input.module, 'event', input.name);
325
+ const component = {
326
+ id,
327
+ type: 'Event',
328
+ name: input.name,
329
+ domain: input.domain,
330
+ module: input.module,
331
+ eventName: input.eventName,
332
+ sourceLocation: input.sourceLocation,
333
+ ...(input.eventSchema !== undefined && { eventSchema: input.eventSchema }),
334
+ ...(input.description !== undefined && { description: input.description }),
335
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
336
+ };
337
+ return this.registerComponent(component);
338
+ }
339
+ /**
340
+ * Adds an EventHandler component to the graph.
341
+ *
342
+ * @param input - EventHandler component properties including subscribed events
343
+ * @returns The created EventHandler component with generated ID
344
+ * @throws If the specified domain does not exist
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * const handler = builder.addEventHandler({
349
+ * name: 'Send Confirmation Email',
350
+ * domain: 'notifications',
351
+ * module: 'email',
352
+ * subscribedEvents: ['OrderPlaced'],
353
+ * sourceLocation: { file: 'src/handlers/OrderConfirmation.ts', line: 10 }
354
+ * })
355
+ * ```
356
+ */
357
+ addEventHandler(input) {
358
+ this.validateDomainExists(input.domain);
359
+ const id = this.generateComponentId(input.domain, input.module, 'eventhandler', input.name);
360
+ const component = {
361
+ id,
362
+ type: 'EventHandler',
363
+ name: input.name,
364
+ domain: input.domain,
365
+ module: input.module,
366
+ subscribedEvents: input.subscribedEvents,
367
+ sourceLocation: input.sourceLocation,
368
+ ...(input.description !== undefined && { description: input.description }),
369
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
370
+ };
371
+ return this.registerComponent(component);
372
+ }
373
+ /**
374
+ * Defines a custom component type for the graph.
375
+ *
376
+ * Custom types allow extending the schema with domain-specific component kinds.
377
+ * Must be defined before adding custom components of that type.
378
+ *
379
+ * @param input - Custom type definition with required and optional properties
380
+ * @throws If a custom type with the same name already exists
381
+ *
382
+ * @example
383
+ * ```typescript
384
+ * builder.defineCustomType({
385
+ * name: 'MessageQueue',
386
+ * description: 'Async message queue',
387
+ * requiredProperties: {
388
+ * queueName: { type: 'string', description: 'Queue identifier' }
389
+ * }
390
+ * })
391
+ * ```
392
+ */
393
+ defineCustomType(input) {
394
+ const customTypes = this.graph.metadata.customTypes;
395
+ if (customTypes[input.name]) {
396
+ throw new Error(`Custom type '${input.name}' already defined`);
397
+ }
398
+ customTypes[input.name] = {
399
+ ...(input.requiredProperties !== undefined && { requiredProperties: input.requiredProperties }),
400
+ ...(input.optionalProperties !== undefined && { optionalProperties: input.optionalProperties }),
401
+ ...(input.description !== undefined && { description: input.description }),
402
+ };
403
+ }
404
+ /**
405
+ * Adds a Custom component to the graph.
406
+ *
407
+ * Custom components use types defined via defineCustomType().
408
+ * Validates that the custom type exists and required properties are provided.
409
+ *
410
+ * @param input - Custom component properties including type name and metadata
411
+ * @returns The created Custom component with generated ID
412
+ * @throws If the specified domain does not exist
413
+ * @throws If the custom type has not been defined
414
+ * @throws If required properties for the custom type are missing
415
+ *
416
+ * @example
417
+ * ```typescript
418
+ * const queue = builder.addCustom({
419
+ * customTypeName: 'MessageQueue',
420
+ * name: 'Order Events Queue',
421
+ * domain: 'orders',
422
+ * module: 'messaging',
423
+ * sourceLocation: { file: 'src/queues/orders.ts', line: 5 },
424
+ * metadata: { queueName: 'order-events' }
425
+ * })
426
+ * ```
427
+ */
428
+ addCustom(input) {
429
+ this.validateDomainExists(input.domain);
430
+ this.validateCustomType(input.customTypeName);
431
+ this.validateRequiredProperties(input.customTypeName, input.metadata);
432
+ const id = this.generateComponentId(input.domain, input.module, 'custom', input.name);
433
+ const component = {
434
+ id,
435
+ type: 'Custom',
436
+ customTypeName: input.customTypeName,
437
+ name: input.name,
438
+ domain: input.domain,
439
+ module: input.module,
440
+ sourceLocation: input.sourceLocation,
441
+ ...(input.description !== undefined && { description: input.description }),
442
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
443
+ };
444
+ return this.registerComponent(component);
445
+ }
446
+ /**
447
+ * Enriches a DomainOp component with additional domain details.
448
+ *
449
+ * Adds state changes and business rules to an existing DomainOp.
450
+ * Multiple enrichments accumulate rather than replace.
451
+ *
452
+ * @param id - The component ID to enrich
453
+ * @param enrichment - State changes and business rules to add
454
+ * @throws If the component does not exist
455
+ * @throws If the component is not a DomainOp type
456
+ *
457
+ * @example
458
+ * ```typescript
459
+ * builder.enrichComponent('orders:fulfillment:domainop:confirm-order', {
460
+ * entity: 'Order',
461
+ * stateChanges: [{ entity: 'Order', from: 'pending', to: 'confirmed' }],
462
+ * businessRules: ['Order must have valid payment']
463
+ * })
464
+ * ```
465
+ */
466
+ enrichComponent(id, enrichment) {
467
+ const component = this.graph.components.find((c) => c.id === id);
468
+ if (!component) {
469
+ throw this.componentNotFoundError(id);
470
+ }
471
+ if (component.type !== 'DomainOp') {
472
+ throw new InvalidEnrichmentTargetError(id, component.type);
473
+ }
474
+ if (enrichment.entity !== undefined) {
475
+ component.entity = enrichment.entity;
476
+ }
477
+ if (enrichment.stateChanges !== undefined) {
478
+ component.stateChanges = [...(component.stateChanges ?? []), ...enrichment.stateChanges];
479
+ }
480
+ if (enrichment.businessRules !== undefined) {
481
+ component.businessRules = [...(component.businessRules ?? []), ...enrichment.businessRules];
482
+ }
483
+ }
484
+ componentNotFoundError(id) {
485
+ return createSourceNotFoundError(this.graph.components, ComponentId.parse(id));
486
+ }
487
+ validateDomainExists(domain) {
488
+ assertDomainExists(this.graph.metadata.domains, domain);
489
+ }
490
+ validateCustomType(customTypeName) {
491
+ assertCustomTypeExists(this.graph.metadata.customTypes, customTypeName);
492
+ }
493
+ validateRequiredProperties(customTypeName, metadata) {
494
+ assertRequiredPropertiesProvided(this.graph.metadata.customTypes, customTypeName, metadata);
495
+ }
496
+ generateComponentId(domain, module, type, name) {
497
+ const nameSegment = name.toLowerCase().replace(/\s+/g, '-');
498
+ return `${domain}:${module}:${type}:${nameSegment}`;
499
+ }
500
+ registerComponent(component) {
501
+ if (this.graph.components.some((c) => c.id === component.id)) {
502
+ throw new DuplicateComponentError(component.id);
503
+ }
504
+ this.graph.components.push(component);
505
+ return component;
506
+ }
507
+ /**
508
+ * Finds components similar to a query for error recovery.
509
+ *
510
+ * Returns fuzzy matches when an exact component lookup fails,
511
+ * enabling actionable error messages with "Did you mean...?" suggestions.
512
+ *
513
+ * @param query - Search criteria including partial ID, name, type, or domain
514
+ * @param options - Optional matching thresholds and limits
515
+ * @returns Array of similar components with similarity scores
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * const matches = builder.nearMatches({ name: 'Place Ordr' })
520
+ * // [{ component: {...}, score: 0.9, mismatches: [...] }]
521
+ * ```
522
+ */
523
+ nearMatches(query, options) {
524
+ return findNearMatches(this.graph.components, query, options);
525
+ }
526
+ /**
527
+ * Creates a link between two components in the graph.
528
+ *
529
+ * Source component must exist; target validation is deferred to build().
530
+ * Use linkExternal() for connections to external systems.
531
+ *
532
+ * @param input - Link properties including source, target, and type
533
+ * @returns The created link
534
+ * @throws If the source component does not exist
535
+ *
536
+ * @example
537
+ * ```typescript
538
+ * const link = builder.link({
539
+ * from: 'orders:checkout:api:create-order',
540
+ * to: 'orders:checkout:usecase:place-order',
541
+ * type: 'sync'
542
+ * })
543
+ * ```
544
+ */
545
+ link(input) {
546
+ const sourceExists = this.graph.components.some((c) => c.id === input.from);
547
+ if (!sourceExists) {
548
+ throw this.sourceNotFoundError(input.from);
549
+ }
550
+ const link = {
551
+ source: input.from,
552
+ target: input.to,
553
+ ...(input.type !== undefined && { type: input.type }),
554
+ };
555
+ this.graph.links.push(link);
556
+ return link;
557
+ }
558
+ /**
559
+ * Creates a link from a component to an external system.
560
+ *
561
+ * Use this for connections to systems outside the graph,
562
+ * such as third-party APIs or external databases.
563
+ *
564
+ * @param input - External link properties including target system info
565
+ * @returns The created external link
566
+ * @throws If the source component does not exist
567
+ *
568
+ * @example
569
+ * ```typescript
570
+ * const link = builder.linkExternal({
571
+ * from: 'orders:payments:usecase:process-payment',
572
+ * target: { name: 'Stripe API', domain: 'payments' },
573
+ * type: 'sync'
574
+ * })
575
+ * ```
576
+ */
577
+ linkExternal(input) {
578
+ const sourceExists = this.graph.components.some((c) => c.id === input.from);
579
+ if (!sourceExists) {
580
+ throw this.sourceNotFoundError(input.from);
581
+ }
582
+ const externalLink = {
583
+ source: input.from,
584
+ target: input.target,
585
+ ...(input.type !== undefined && { type: input.type }),
586
+ ...(input.description !== undefined && { description: input.description }),
587
+ ...(input.sourceLocation !== undefined && { sourceLocation: input.sourceLocation }),
588
+ ...(input.metadata !== undefined && { metadata: input.metadata }),
589
+ };
590
+ this.graph.externalLinks.push(externalLink);
591
+ return externalLink;
592
+ }
593
+ /**
594
+ * Returns non-fatal issues found in the graph.
595
+ *
596
+ * Warnings indicate potential problems that don't prevent building,
597
+ * such as orphaned components or unused domains.
598
+ *
599
+ * @returns Array of warning objects with type and message
600
+ *
601
+ * @example
602
+ * ```typescript
603
+ * const warnings = builder.warnings()
604
+ * for (const w of warnings) {
605
+ * console.log(`${w.type}: ${w.message}`)
606
+ * }
607
+ * ```
608
+ */
609
+ warnings() {
610
+ return findWarnings(this.graph);
611
+ }
612
+ /**
613
+ * Returns statistics about the current graph state.
614
+ *
615
+ * @returns Counts of components by type, domains, and links
616
+ *
617
+ * @example
618
+ * ```typescript
619
+ * const stats = builder.stats()
620
+ * console.log(`Components: ${stats.componentCount}`)
621
+ * console.log(`Links: ${stats.linkCount}`)
622
+ * ```
623
+ */
624
+ stats() {
625
+ return calculateStats(this.graph);
626
+ }
627
+ /**
628
+ * Runs full validation on the graph.
629
+ *
630
+ * Checks for dangling references, orphans, and schema compliance.
631
+ * Called automatically by build().
632
+ *
633
+ * @returns Validation result with valid flag and error details
634
+ *
635
+ * @example
636
+ * ```typescript
637
+ * const result = builder.validate()
638
+ * if (!result.valid) {
639
+ * for (const error of result.errors) {
640
+ * console.error(error.message)
641
+ * }
642
+ * }
643
+ * ```
644
+ */
645
+ validate() {
646
+ return validateGraph(this.graph);
647
+ }
648
+ /**
649
+ * Returns IDs of components with no incoming or outgoing links.
650
+ *
651
+ * @returns Array of orphaned component IDs
652
+ *
653
+ * @example
654
+ * ```typescript
655
+ * const orphans = builder.orphans()
656
+ * if (orphans.length > 0) {
657
+ * console.warn('Orphaned components:', orphans)
658
+ * }
659
+ * ```
660
+ */
661
+ orphans() {
662
+ return findOrphans(this.graph);
663
+ }
664
+ /**
665
+ * Returns a RiviereQuery instance for the current graph state.
666
+ *
667
+ * Enables querying mid-construction without affecting builder state.
668
+ *
669
+ * @returns RiviereQuery instance for the current graph
670
+ *
671
+ * @example
672
+ * ```typescript
673
+ * const query = builder.query()
674
+ * const apis = query.componentsByType('API')
675
+ * ```
676
+ */
677
+ query() {
678
+ return new RiviereQuery(toRiviereGraph(this.graph));
679
+ }
680
+ /**
681
+ * Serializes the current graph state as a JSON string.
682
+ *
683
+ * Does not validate. Use for saving drafts mid-construction
684
+ * that can be resumed later with RiviereBuilder.resume().
685
+ *
686
+ * @returns JSON string representation of the graph
687
+ *
688
+ * @example
689
+ * ```typescript
690
+ * const json = builder.serialize()
691
+ * await fs.writeFile('draft.json', json)
692
+ * ```
693
+ */
694
+ serialize() {
695
+ return JSON.stringify(this.graph, null, 2);
696
+ }
697
+ /**
698
+ * Validates and returns the completed graph.
699
+ *
700
+ * @returns Valid RiviereGraph object
701
+ * @throws If validation fails with error details
702
+ *
703
+ * @example
704
+ * ```typescript
705
+ * try {
706
+ * const graph = builder.build()
707
+ * console.log('Graph built successfully')
708
+ * } catch (error) {
709
+ * console.error('Build failed:', error.message)
710
+ * }
711
+ * ```
712
+ */
713
+ build() {
714
+ const result = this.validate();
715
+ if (!result.valid) {
716
+ const messages = result.errors.map((e) => e.message).join('; ');
717
+ throw new Error(`Validation failed: ${messages}`);
718
+ }
719
+ return toRiviereGraph(this.graph);
720
+ }
721
+ /**
722
+ * Validates the graph and writes it to a file.
723
+ *
724
+ * @param path - Absolute or relative file path to write
725
+ * @throws If validation fails
726
+ * @throws If the directory does not exist
727
+ * @throws If write fails
728
+ *
729
+ * @example
730
+ * ```typescript
731
+ * await builder.save('./output/architecture.json')
732
+ * ```
733
+ */
734
+ async save(path) {
735
+ const graph = this.build();
736
+ const dir = dirname(path);
737
+ try {
738
+ await fs.access(dir);
739
+ }
740
+ catch {
741
+ throw new Error(`Directory does not exist: ${dir}`);
742
+ }
743
+ const json = JSON.stringify(graph, null, 2);
744
+ await fs.writeFile(path, json, 'utf-8');
745
+ }
746
+ sourceNotFoundError(id) {
747
+ return createSourceNotFoundError(this.graph.components, ComponentId.parse(id));
748
+ }
749
+ }