capdag 0.90.20904 → 0.92.23685

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/capdag.js CHANGED
@@ -94,6 +94,24 @@ class CapUrn {
94
94
  return this.outSpec;
95
95
  }
96
96
 
97
+ /**
98
+ * Parse the in= spec into a MediaUrn.
99
+ * @returns {MediaUrn} The input media URN
100
+ * @throws {MediaUrnError} If the in spec is not a valid media URN
101
+ */
102
+ inMediaUrn() {
103
+ return MediaUrn.fromString(this.inSpec);
104
+ }
105
+
106
+ /**
107
+ * Parse the out= spec into a MediaUrn.
108
+ * @returns {MediaUrn} The output media URN
109
+ * @throws {MediaUrnError} If the out spec is not a valid media URN
110
+ */
111
+ outMediaUrn() {
112
+ return MediaUrn.fromString(this.outSpec);
113
+ }
114
+
97
115
  /**
98
116
  * Create a Cap URN from string representation
99
117
  * Format: cap:in="<media-urn>";out="<media-urn>";key1=value1;key2=value2;...
@@ -508,6 +526,27 @@ class CapUrn {
508
526
  return new CapUrn(other.inSpec, other.outSpec, newTags);
509
527
  }
510
528
 
529
+ /**
530
+ * Check if two cap URNs are comparable (on the same specialization chain).
531
+ * isComparable(other) ≡ accepts(other) || other.accepts(this)
532
+ * @param {CapUrn} other
533
+ * @returns {boolean}
534
+ */
535
+ isComparable(other) {
536
+ return this.accepts(other) || other.accepts(this);
537
+ }
538
+
539
+ /**
540
+ * Check if two cap URNs are equivalent in the order-theoretic sense.
541
+ * Two URNs are equivalent if each accepts (subsumes) the other.
542
+ * isEquivalent(other) ≡ accepts(other) && other.accepts(this)
543
+ * @param {CapUrn} other
544
+ * @returns {boolean}
545
+ */
546
+ isEquivalent(other) {
547
+ return this.accepts(other) && other.accepts(this);
548
+ }
549
+
511
550
  /**
512
551
  * Check if this cap URN is equal to another
513
552
  * Compares direction specs (in/out) and tags
@@ -1056,6 +1095,22 @@ class MediaUrn {
1056
1095
  /** @returns {string} Canonical string representation */
1057
1096
  toString() { return this._urn.toString(); }
1058
1097
 
1098
+ /**
1099
+ * Check if two media URNs are equivalent (each accepts the other).
1100
+ * isEquivalent(other) ≡ accepts(other) && other.accepts(this)
1101
+ * @param {MediaUrn} other
1102
+ * @returns {boolean}
1103
+ */
1104
+ isEquivalent(other) { return this._urn.isEquivalent(other._urn); }
1105
+
1106
+ /**
1107
+ * Check if two media URNs are comparable (on the same specialization chain).
1108
+ * isComparable(other) ≡ accepts(other) || other.accepts(this)
1109
+ * @param {MediaUrn} other
1110
+ * @returns {boolean}
1111
+ */
1112
+ isComparable(other) { return this._urn.isComparable(other._urn); }
1113
+
1059
1114
  /**
1060
1115
  * @param {MediaUrn} other
1061
1116
  * @returns {boolean}
@@ -4201,6 +4256,732 @@ class PluginRepoServer {
4201
4256
  }
4202
4257
  }
4203
4258
 
4259
+ // ============================================================================
4260
+ // Route Notation — compact, round-trippable DAG path identifiers
4261
+ //
4262
+ // Route notation describes capability transformation paths using bracket-
4263
+ // delimited statements:
4264
+ // [alias cap:in="...";op=...;out="..."] — header (defines a cap with alias)
4265
+ // [src -> alias -> dst] — wiring (connects nodes via cap)
4266
+ // [(a, b) -> alias -> dst] — fan-in wiring
4267
+ // [src -> LOOP alias -> dst] — loop wiring (ForEach semantics)
4268
+ // ============================================================================
4269
+
4270
+ /**
4271
+ * Error types for route notation parsing.
4272
+ * Mirrors Rust RouteNotationError exactly.
4273
+ */
4274
+ class RouteNotationError extends Error {
4275
+ constructor(code, message) {
4276
+ super(message);
4277
+ this.name = 'RouteNotationError';
4278
+ this.code = code;
4279
+ }
4280
+ }
4281
+
4282
+ const RouteNotationErrorCodes = {
4283
+ EMPTY: 'Empty',
4284
+ UNTERMINATED_STATEMENT: 'UnterminatedStatement',
4285
+ INVALID_CAP_URN: 'InvalidCapUrn',
4286
+ UNDEFINED_ALIAS: 'UndefinedAlias',
4287
+ DUPLICATE_ALIAS: 'DuplicateAlias',
4288
+ INVALID_WIRING: 'InvalidWiring',
4289
+ INVALID_MEDIA_URN: 'InvalidMediaUrn',
4290
+ INVALID_HEADER: 'InvalidHeader',
4291
+ NO_EDGES: 'NoEdges',
4292
+ NODE_ALIAS_COLLISION: 'NodeAliasCollision',
4293
+ PARSE_ERROR: 'ParseError',
4294
+ };
4295
+
4296
+ /**
4297
+ * A single edge in the route graph.
4298
+ *
4299
+ * Each edge represents a capability that transforms one or more source
4300
+ * media types into a target media type. The isLoop flag indicates
4301
+ * ForEach semantics (the capability is applied to each item in a list).
4302
+ *
4303
+ * Mirrors Rust RouteEdge.
4304
+ */
4305
+ class RouteEdge {
4306
+ /**
4307
+ * @param {MediaUrn[]} sources - Input media URN(s)
4308
+ * @param {CapUrn} capUrn - The capability URN (edge label)
4309
+ * @param {MediaUrn} target - Output media URN
4310
+ * @param {boolean} isLoop - Whether this edge has ForEach semantics
4311
+ */
4312
+ constructor(sources, capUrn, target, isLoop) {
4313
+ this.sources = sources;
4314
+ this.capUrn = capUrn;
4315
+ this.target = target;
4316
+ this.isLoop = isLoop;
4317
+ }
4318
+
4319
+ /**
4320
+ * Check if two edges are semantically equivalent.
4321
+ *
4322
+ * Equivalence is defined as:
4323
+ * - Same number of sources, and each source in this has an equivalent source in other
4324
+ * - Equivalent cap URNs (via CapUrn.isEquivalent)
4325
+ * - Equivalent target media URNs (via MediaUrn.isEquivalent)
4326
+ * - Same isLoop flag
4327
+ *
4328
+ * Source order does not matter — fan-in sources are compared as sets.
4329
+ * Mirrors Rust RouteEdge::is_equivalent.
4330
+ */
4331
+ isEquivalent(other) {
4332
+ if (this.isLoop !== other.isLoop) {
4333
+ return false;
4334
+ }
4335
+
4336
+ if (!this.capUrn.isEquivalent(other.capUrn)) {
4337
+ return false;
4338
+ }
4339
+
4340
+ // Target equivalence
4341
+ if (!this.target.isEquivalent(other.target)) {
4342
+ return false;
4343
+ }
4344
+
4345
+ // Source set equivalence — order-independent comparison
4346
+ if (this.sources.length !== other.sources.length) {
4347
+ return false;
4348
+ }
4349
+
4350
+ // For each source in this, find a matching source in other.
4351
+ // Track which indices in other have been matched to avoid double-counting.
4352
+ const matched = new Array(other.sources.length).fill(false);
4353
+ for (const selfSrc of this.sources) {
4354
+ let found = false;
4355
+ for (let j = 0; j < other.sources.length; j++) {
4356
+ if (matched[j]) continue;
4357
+ if (selfSrc.isEquivalent(other.sources[j])) {
4358
+ matched[j] = true;
4359
+ found = true;
4360
+ break;
4361
+ }
4362
+ }
4363
+ if (!found) {
4364
+ return false;
4365
+ }
4366
+ }
4367
+
4368
+ return true;
4369
+ }
4370
+
4371
+ /**
4372
+ * Display string for this edge.
4373
+ * Mirrors Rust Display for RouteEdge.
4374
+ */
4375
+ toString() {
4376
+ const sources = this.sources.map(s => s.toString()).join(', ');
4377
+ const loopPrefix = this.isLoop ? 'LOOP ' : '';
4378
+ return `(${sources}) -${loopPrefix}${this.capUrn}-> ${this.target}`;
4379
+ }
4380
+ }
4381
+
4382
+ /**
4383
+ * A route graph — the semantic model behind route notation.
4384
+ *
4385
+ * The graph is a collection of directed edges where each edge is a capability
4386
+ * that transforms source media types into a target media type.
4387
+ *
4388
+ * Two graphs are equivalent if they have the same set of edges, regardless
4389
+ * of ordering. Alias names used in the textual notation are not part of
4390
+ * the graph model.
4391
+ *
4392
+ * Mirrors Rust RouteGraph.
4393
+ */
4394
+ class RouteGraph {
4395
+ /**
4396
+ * @param {RouteEdge[]} edges
4397
+ */
4398
+ constructor(edges) {
4399
+ this._edges = edges;
4400
+ }
4401
+
4402
+ /**
4403
+ * Create an empty route graph.
4404
+ * @returns {RouteGraph}
4405
+ */
4406
+ static empty() {
4407
+ return new RouteGraph([]);
4408
+ }
4409
+
4410
+ /**
4411
+ * Parse route notation into a RouteGraph.
4412
+ * @param {string} input
4413
+ * @returns {RouteGraph}
4414
+ * @throws {RouteNotationError}
4415
+ */
4416
+ static fromString(input) {
4417
+ return parseRouteNotation(input);
4418
+ }
4419
+
4420
+ /**
4421
+ * Get the edges of this graph.
4422
+ * @returns {RouteEdge[]}
4423
+ */
4424
+ edges() {
4425
+ return this._edges;
4426
+ }
4427
+
4428
+ /**
4429
+ * Number of edges in the graph.
4430
+ * @returns {number}
4431
+ */
4432
+ edgeCount() {
4433
+ return this._edges.length;
4434
+ }
4435
+
4436
+ /**
4437
+ * Check if the graph has no edges.
4438
+ * @returns {boolean}
4439
+ */
4440
+ isEmpty() {
4441
+ return this._edges.length === 0;
4442
+ }
4443
+
4444
+ /**
4445
+ * Check if two route graphs are semantically equivalent.
4446
+ *
4447
+ * Two graphs are equivalent if they have the same set of edges
4448
+ * (compared using RouteEdge.isEquivalent). Edge ordering
4449
+ * does not matter.
4450
+ *
4451
+ * Mirrors Rust RouteGraph::is_equivalent.
4452
+ */
4453
+ isEquivalent(other) {
4454
+ if (this._edges.length !== other._edges.length) {
4455
+ return false;
4456
+ }
4457
+
4458
+ // For each edge in this, find a matching edge in other.
4459
+ const matched = new Array(other._edges.length).fill(false);
4460
+ for (const selfEdge of this._edges) {
4461
+ let found = false;
4462
+ for (let j = 0; j < other._edges.length; j++) {
4463
+ if (matched[j]) continue;
4464
+ if (selfEdge.isEquivalent(other._edges[j])) {
4465
+ matched[j] = true;
4466
+ found = true;
4467
+ break;
4468
+ }
4469
+ }
4470
+ if (!found) {
4471
+ return false;
4472
+ }
4473
+ }
4474
+
4475
+ return true;
4476
+ }
4477
+
4478
+ /**
4479
+ * Collect all unique source media URNs across all edges that are not
4480
+ * also produced as targets by any other edge. These are the "root"
4481
+ * inputs to the graph.
4482
+ *
4483
+ * Mirrors Rust RouteGraph::root_sources.
4484
+ * @returns {MediaUrn[]}
4485
+ */
4486
+ rootSources() {
4487
+ const roots = [];
4488
+ for (const edge of this._edges) {
4489
+ for (const src of edge.sources) {
4490
+ // Check if any edge produces this source as a target
4491
+ const isProduced = this._edges.some(e => e.target.isEquivalent(src));
4492
+ if (!isProduced) {
4493
+ // Avoid duplicates (by equivalence)
4494
+ const alreadyAdded = roots.some(r => r.isEquivalent(src));
4495
+ if (!alreadyAdded) {
4496
+ roots.push(src);
4497
+ }
4498
+ }
4499
+ }
4500
+ }
4501
+ return roots;
4502
+ }
4503
+
4504
+ /**
4505
+ * Collect all unique target media URNs that are not consumed as sources
4506
+ * by any other edge. These are the "leaf" outputs of the graph.
4507
+ *
4508
+ * Mirrors Rust RouteGraph::leaf_targets.
4509
+ * @returns {MediaUrn[]}
4510
+ */
4511
+ leafTargets() {
4512
+ const leaves = [];
4513
+ for (const edge of this._edges) {
4514
+ const isConsumed = this._edges.some(e =>
4515
+ e.sources.some(s => s.isEquivalent(edge.target))
4516
+ );
4517
+ if (!isConsumed) {
4518
+ const alreadyAdded = leaves.some(l => l.isEquivalent(edge.target));
4519
+ if (!alreadyAdded) {
4520
+ leaves.push(edge.target);
4521
+ }
4522
+ }
4523
+ }
4524
+ return leaves;
4525
+ }
4526
+
4527
+ // =========================================================================
4528
+ // Serializer — deterministic canonical form
4529
+ // Mirrors Rust serializer.rs
4530
+ // =========================================================================
4531
+
4532
+ /**
4533
+ * Serialize this route graph to canonical one-line route notation.
4534
+ *
4535
+ * The output is deterministic: same graph → same string.
4536
+ * Mirrors Rust RouteGraph::to_route_notation.
4537
+ * @returns {string}
4538
+ */
4539
+ toRouteNotation() {
4540
+ if (this._edges.length === 0) {
4541
+ return '';
4542
+ }
4543
+
4544
+ const { aliases, nodeNames, edgeOrder } = this._buildSerializationMaps();
4545
+ let output = '';
4546
+
4547
+ // Emit headers in alias-sorted order
4548
+ const sortedAliases = Array.from(aliases.entries()).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
4549
+
4550
+ for (const [alias, { edgeIdx }] of sortedAliases) {
4551
+ const edge = this._edges[edgeIdx];
4552
+ output += `[${alias} ${edge.capUrn}]`;
4553
+ }
4554
+
4555
+ // Emit wirings in edge order
4556
+ for (const edgeIdx of edgeOrder) {
4557
+ const edge = this._edges[edgeIdx];
4558
+ let alias = null;
4559
+ for (const [a, info] of aliases) {
4560
+ if (info.edgeIdx === edgeIdx) {
4561
+ alias = a;
4562
+ break;
4563
+ }
4564
+ }
4565
+
4566
+ // Source node name(s)
4567
+ const sources = edge.sources.map(s => {
4568
+ const key = s.toString();
4569
+ return nodeNames.get(key);
4570
+ });
4571
+
4572
+ // Target node name
4573
+ const targetKey = edge.target.toString();
4574
+ const targetName = nodeNames.get(targetKey);
4575
+
4576
+ const loopPrefix = edge.isLoop ? 'LOOP ' : '';
4577
+
4578
+ if (sources.length === 1) {
4579
+ output += `[${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}]`;
4580
+ } else {
4581
+ const group = sources.join(', ');
4582
+ output += `[(${group}) -> ${loopPrefix}${alias} -> ${targetName}]`;
4583
+ }
4584
+ }
4585
+
4586
+ return output;
4587
+ }
4588
+
4589
+ /**
4590
+ * Serialize to multi-line route notation (one statement per line).
4591
+ * Mirrors Rust RouteGraph::to_route_notation_multiline.
4592
+ * @returns {string}
4593
+ */
4594
+ toRouteNotationMultiline() {
4595
+ if (this._edges.length === 0) {
4596
+ return '';
4597
+ }
4598
+
4599
+ const { aliases, nodeNames, edgeOrder } = this._buildSerializationMaps();
4600
+ const lines = [];
4601
+
4602
+ // Emit headers in alias-sorted order
4603
+ const sortedAliases = Array.from(aliases.entries()).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
4604
+
4605
+ for (const [alias, { edgeIdx }] of sortedAliases) {
4606
+ const edge = this._edges[edgeIdx];
4607
+ lines.push(`[${alias} ${edge.capUrn}]`);
4608
+ }
4609
+
4610
+ // Emit wirings in edge order
4611
+ for (const edgeIdx of edgeOrder) {
4612
+ const edge = this._edges[edgeIdx];
4613
+ let alias = null;
4614
+ for (const [a, info] of aliases) {
4615
+ if (info.edgeIdx === edgeIdx) {
4616
+ alias = a;
4617
+ break;
4618
+ }
4619
+ }
4620
+
4621
+ const sources = edge.sources.map(s => {
4622
+ const key = s.toString();
4623
+ return nodeNames.get(key);
4624
+ });
4625
+
4626
+ const targetKey = edge.target.toString();
4627
+ const targetName = nodeNames.get(targetKey);
4628
+
4629
+ const loopPrefix = edge.isLoop ? 'LOOP ' : '';
4630
+
4631
+ if (sources.length === 1) {
4632
+ lines.push(`[${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}]`);
4633
+ } else {
4634
+ const group = sources.join(', ');
4635
+ lines.push(`[(${group}) -> ${loopPrefix}${alias} -> ${targetName}]`);
4636
+ }
4637
+ }
4638
+
4639
+ return lines.join('\n');
4640
+ }
4641
+
4642
+ /**
4643
+ * Build the alias map, node name map, and edge ordering for serialization.
4644
+ *
4645
+ * Returns:
4646
+ * - aliases: Map<string, { edgeIdx: number, capStr: string }>
4647
+ * - nodeNames: Map<string, string> (media_urn_canonical → node_name)
4648
+ * - edgeOrder: number[] (edge indices in canonical order)
4649
+ *
4650
+ * Mirrors Rust RouteGraph::build_serialization_maps.
4651
+ * @private
4652
+ */
4653
+ _buildSerializationMaps() {
4654
+ // Step 1: Generate canonical edge ordering
4655
+ const edgeOrder = Array.from({ length: this._edges.length }, (_, i) => i);
4656
+ edgeOrder.sort((a, b) => {
4657
+ const ea = this._edges[a];
4658
+ const eb = this._edges[b];
4659
+
4660
+ const capCmp = ea.capUrn.toString().localeCompare(eb.capUrn.toString());
4661
+ if (capCmp !== 0) return capCmp;
4662
+
4663
+ const srcA = ea.sources.map(s => s.toString());
4664
+ const srcB = eb.sources.map(s => s.toString());
4665
+ // Lexicographic comparison of source arrays
4666
+ const minLen = Math.min(srcA.length, srcB.length);
4667
+ for (let i = 0; i < minLen; i++) {
4668
+ const cmp = srcA[i].localeCompare(srcB[i]);
4669
+ if (cmp !== 0) return cmp;
4670
+ }
4671
+ if (srcA.length !== srcB.length) return srcA.length - srcB.length;
4672
+
4673
+ return ea.target.toString().localeCompare(eb.target.toString());
4674
+ });
4675
+
4676
+ // Step 2: Generate aliases from op= tag
4677
+ const aliases = new Map();
4678
+ const aliasCounts = new Map();
4679
+
4680
+ for (const idx of edgeOrder) {
4681
+ const edge = this._edges[idx];
4682
+ const opTag = edge.capUrn.getTag('op');
4683
+ const baseAlias = opTag !== undefined ? opTag : `edge_${idx}`;
4684
+
4685
+ const count = aliasCounts.get(baseAlias) || 0;
4686
+ const alias = count === 0 ? baseAlias : `${baseAlias}_${count}`;
4687
+ aliasCounts.set(baseAlias, count + 1);
4688
+
4689
+ const capStr = edge.capUrn.toString();
4690
+ aliases.set(alias, { edgeIdx: idx, capStr });
4691
+ }
4692
+
4693
+ // Step 3: Generate node names
4694
+ // Collect all unique media URNs, assign names in order of first appearance
4695
+ const nodeNames = new Map();
4696
+ let nodeCounter = 0;
4697
+
4698
+ for (const idx of edgeOrder) {
4699
+ const edge = this._edges[idx];
4700
+ for (const src of edge.sources) {
4701
+ const key = src.toString();
4702
+ if (!nodeNames.has(key)) {
4703
+ nodeNames.set(key, `n${nodeCounter}`);
4704
+ nodeCounter++;
4705
+ }
4706
+ }
4707
+ const targetKey = edge.target.toString();
4708
+ if (!nodeNames.has(targetKey)) {
4709
+ nodeNames.set(targetKey, `n${nodeCounter}`);
4710
+ nodeCounter++;
4711
+ }
4712
+ }
4713
+
4714
+ return { aliases, nodeNames, edgeOrder };
4715
+ }
4716
+
4717
+ /**
4718
+ * Display string for this graph.
4719
+ * Mirrors Rust Display for RouteGraph.
4720
+ */
4721
+ toString() {
4722
+ if (this._edges.length === 0) {
4723
+ return 'RouteGraph(empty)';
4724
+ }
4725
+ return `RouteGraph(${this._edges.length} edges)`;
4726
+ }
4727
+ }
4728
+
4729
+ // ============================================================================
4730
+ // Route Parser — PEG-based parser using Peggy
4731
+ // Mirrors Rust parser.rs exactly (4-phase pipeline)
4732
+ // ============================================================================
4733
+
4734
+ // Load the Peggy-generated parser
4735
+ const routeParser = require('./route-parser.js');
4736
+
4737
+ /**
4738
+ * Assign a media URN to a node, or check consistency if already assigned.
4739
+ *
4740
+ * Uses MediaUrn.isComparable() — two types on the same specialization
4741
+ * chain are compatible.
4742
+ *
4743
+ * Mirrors Rust assign_or_check_node.
4744
+ * @private
4745
+ */
4746
+ function assignOrCheckNode(node, mediaUrn, nodeMedia, position) {
4747
+ const existing = nodeMedia.get(node);
4748
+ if (existing !== undefined) {
4749
+ const compatible = existing.isComparable(mediaUrn);
4750
+ if (!compatible) {
4751
+ throw new RouteNotationError(
4752
+ RouteNotationErrorCodes.INVALID_WIRING,
4753
+ `invalid wiring at statement ${position}: node '${node}' has conflicting media types: existing '${existing}', new '${mediaUrn}'`
4754
+ );
4755
+ }
4756
+ } else {
4757
+ nodeMedia.set(node, mediaUrn);
4758
+ }
4759
+ }
4760
+
4761
+ /**
4762
+ * Parse route notation into a RouteGraph.
4763
+ *
4764
+ * Uses the Peggy-generated PEG parser to parse the input, then resolves
4765
+ * cap URNs and derives media URNs from cap in/out specs.
4766
+ *
4767
+ * Fails hard — no fallbacks, no guessing, no recovery.
4768
+ *
4769
+ * Mirrors Rust parse_route_notation exactly.
4770
+ *
4771
+ * @param {string} input - Route notation string
4772
+ * @returns {RouteGraph}
4773
+ * @throws {RouteNotationError}
4774
+ */
4775
+ function parseRouteNotation(input) {
4776
+ const trimmed = input.trim();
4777
+ if (trimmed.length === 0) {
4778
+ throw new RouteNotationError(
4779
+ RouteNotationErrorCodes.EMPTY,
4780
+ 'route notation is empty'
4781
+ );
4782
+ }
4783
+
4784
+ // Phase 1: Parse with Peggy grammar
4785
+ let stmts;
4786
+ try {
4787
+ stmts = routeParser.parse(trimmed);
4788
+ } catch (e) {
4789
+ throw new RouteNotationError(
4790
+ RouteNotationErrorCodes.PARSE_ERROR,
4791
+ `parse error: ${e.message}`
4792
+ );
4793
+ }
4794
+
4795
+ // Phase 2: Separate headers and wirings (already done by grammar actions)
4796
+ const headers = []; // { alias, capUrn, position }
4797
+ const wirings = []; // { sources, capAlias, target, isLoop, position }
4798
+
4799
+ for (let i = 0; i < stmts.length; i++) {
4800
+ const stmt = stmts[i];
4801
+ if (stmt.type === 'header') {
4802
+ // Parse the cap URN string
4803
+ let capUrn;
4804
+ try {
4805
+ capUrn = CapUrn.fromString(stmt.capUrn);
4806
+ } catch (e) {
4807
+ throw new RouteNotationError(
4808
+ RouteNotationErrorCodes.INVALID_CAP_URN,
4809
+ `invalid cap URN in header '${stmt.alias}': ${e.message}`
4810
+ );
4811
+ }
4812
+ headers.push({ alias: stmt.alias, capUrn, position: i });
4813
+ } else if (stmt.type === 'wiring') {
4814
+ wirings.push({
4815
+ sources: stmt.sources,
4816
+ capAlias: stmt.capAlias,
4817
+ target: stmt.target,
4818
+ isLoop: stmt.isLoop,
4819
+ position: i,
4820
+ });
4821
+ }
4822
+ }
4823
+
4824
+ // Phase 3: Build alias → CapUrn map, checking for duplicates
4825
+ const aliasMap = new Map(); // alias → { capUrn, position }
4826
+ for (const header of headers) {
4827
+ if (aliasMap.has(header.alias)) {
4828
+ const firstPos = aliasMap.get(header.alias).position;
4829
+ throw new RouteNotationError(
4830
+ RouteNotationErrorCodes.DUPLICATE_ALIAS,
4831
+ `duplicate alias '${header.alias}' (first defined at statement ${firstPos})`
4832
+ );
4833
+ }
4834
+ aliasMap.set(header.alias, { capUrn: header.capUrn, position: header.position });
4835
+ }
4836
+
4837
+ // Phase 4: Resolve wirings into RouteEdges
4838
+ if (wirings.length === 0 && headers.length > 0) {
4839
+ throw new RouteNotationError(
4840
+ RouteNotationErrorCodes.NO_EDGES,
4841
+ 'route has headers but no wirings — define at least one edge'
4842
+ );
4843
+ }
4844
+
4845
+ const nodeMedia = new Map(); // node_name → MediaUrn
4846
+ const edges = [];
4847
+
4848
+ for (const wiring of wirings) {
4849
+ // Look up the cap alias
4850
+ const aliasEntry = aliasMap.get(wiring.capAlias);
4851
+ if (!aliasEntry) {
4852
+ throw new RouteNotationError(
4853
+ RouteNotationErrorCodes.UNDEFINED_ALIAS,
4854
+ `wiring references undefined alias '${wiring.capAlias}'`
4855
+ );
4856
+ }
4857
+ const capUrn = aliasEntry.capUrn;
4858
+
4859
+ // Check node-alias collisions
4860
+ for (const src of wiring.sources) {
4861
+ if (aliasMap.has(src)) {
4862
+ throw new RouteNotationError(
4863
+ RouteNotationErrorCodes.NODE_ALIAS_COLLISION,
4864
+ `node name '${src}' collides with cap alias '${src}'`
4865
+ );
4866
+ }
4867
+ }
4868
+ if (aliasMap.has(wiring.target)) {
4869
+ throw new RouteNotationError(
4870
+ RouteNotationErrorCodes.NODE_ALIAS_COLLISION,
4871
+ `node name '${wiring.target}' collides with cap alias '${wiring.target}'`
4872
+ );
4873
+ }
4874
+
4875
+ // Derive media URNs from cap's in=/out= specs
4876
+ let capInMedia;
4877
+ try {
4878
+ capInMedia = capUrn.inMediaUrn();
4879
+ } catch (e) {
4880
+ throw new RouteNotationError(
4881
+ RouteNotationErrorCodes.INVALID_MEDIA_URN,
4882
+ `invalid media URN in cap '${wiring.capAlias}': in= spec: ${e.message}`
4883
+ );
4884
+ }
4885
+
4886
+ let capOutMedia;
4887
+ try {
4888
+ capOutMedia = capUrn.outMediaUrn();
4889
+ } catch (e) {
4890
+ throw new RouteNotationError(
4891
+ RouteNotationErrorCodes.INVALID_MEDIA_URN,
4892
+ `invalid media URN in cap '${wiring.capAlias}': out= spec: ${e.message}`
4893
+ );
4894
+ }
4895
+
4896
+ // Resolve source media URNs
4897
+ const sourceUrns = [];
4898
+ for (let i = 0; i < wiring.sources.length; i++) {
4899
+ const src = wiring.sources[i];
4900
+ if (i === 0) {
4901
+ // Primary source: use cap's in= spec
4902
+ assignOrCheckNode(src, capInMedia, nodeMedia, wiring.position);
4903
+ sourceUrns.push(capInMedia);
4904
+ } else {
4905
+ // Secondary source (fan-in): use existing type if assigned,
4906
+ // otherwise use wildcard media: — the orchestrator parser will
4907
+ // resolve the real type from the cap's args via registry lookup.
4908
+ let secondaryMedia = nodeMedia.get(src);
4909
+ if (secondaryMedia === undefined) {
4910
+ secondaryMedia = MediaUrn.fromString('media:');
4911
+ nodeMedia.set(src, secondaryMedia);
4912
+ }
4913
+ sourceUrns.push(secondaryMedia);
4914
+ }
4915
+ }
4916
+
4917
+ // Assign target media URN
4918
+ assignOrCheckNode(wiring.target, capOutMedia, nodeMedia, wiring.position);
4919
+
4920
+ edges.push(new RouteEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
4921
+ }
4922
+
4923
+ return new RouteGraph(edges);
4924
+ }
4925
+
4926
+ // ============================================================================
4927
+ // RouteGraphBuilder — programmatic path construction
4928
+ // ============================================================================
4929
+
4930
+ /**
4931
+ * Builder for constructing RouteGraphs programmatically.
4932
+ *
4933
+ * Provides a fluent API for building route graphs without writing
4934
+ * route notation strings. Useful for constructing paths from graph
4935
+ * exploration (e.g., selecting paths in the UI).
4936
+ */
4937
+ class RouteGraphBuilder {
4938
+ constructor() {
4939
+ this._edges = [];
4940
+ }
4941
+
4942
+ /**
4943
+ * Add an edge to the graph.
4944
+ * @param {string[]} sourceUrns - Source media URN strings
4945
+ * @param {string} capUrnStr - Cap URN string
4946
+ * @param {string} targetUrn - Target media URN string
4947
+ * @param {boolean} [isLoop=false] - Whether this edge has ForEach semantics
4948
+ * @returns {RouteGraphBuilder} this (for chaining)
4949
+ */
4950
+ addEdge(sourceUrns, capUrnStr, targetUrn, isLoop = false) {
4951
+ const sources = sourceUrns.map(s => MediaUrn.fromString(s));
4952
+ const capUrn = CapUrn.fromString(capUrnStr);
4953
+ const target = MediaUrn.fromString(targetUrn);
4954
+ this._edges.push(new RouteEdge(sources, capUrn, target, isLoop));
4955
+ return this;
4956
+ }
4957
+
4958
+ /**
4959
+ * Add a linear chain of edges from CapGraphEdge[] (from CapGraph.findAllPaths).
4960
+ *
4961
+ * Each CapGraphEdge has fromUrn, toUrn, and cap (with cap.urn).
4962
+ * This converts the path into a series of RouteEdges.
4963
+ *
4964
+ * @param {CapGraphEdge[]} capGraphEdges - Array of CapGraphEdge from pathfinding
4965
+ * @returns {RouteGraphBuilder} this (for chaining)
4966
+ */
4967
+ addCapGraphPath(capGraphEdges) {
4968
+ for (const edge of capGraphEdges) {
4969
+ const source = MediaUrn.fromString(edge.fromUrn);
4970
+ const target = MediaUrn.fromString(edge.toUrn);
4971
+ this._edges.push(new RouteEdge([source], edge.cap.urn, target, false));
4972
+ }
4973
+ return this;
4974
+ }
4975
+
4976
+ /**
4977
+ * Build the RouteGraph from the accumulated edges.
4978
+ * @returns {RouteGraph}
4979
+ */
4980
+ build() {
4981
+ return new RouteGraph([...this._edges]);
4982
+ }
4983
+ }
4984
+
4204
4985
  // Export for CommonJS
4205
4986
  module.exports = {
4206
4987
  CapUrn,
@@ -4318,5 +5099,12 @@ module.exports = {
4318
5099
  PluginSuggestion,
4319
5100
  PluginRepoCache,
4320
5101
  PluginRepoClient,
4321
- PluginRepoServer
5102
+ PluginRepoServer,
5103
+ // Route notation
5104
+ RouteNotationError,
5105
+ RouteNotationErrorCodes,
5106
+ RouteEdge,
5107
+ RouteGraph,
5108
+ RouteGraphBuilder,
5109
+ parseRouteNotation,
4322
5110
  };