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 +789 -1
- package/capdag.test.js +770 -0
- package/package.json +7 -2
- package/route-parser.js +1120 -0
- package/route.pegjs +55 -0
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
|
};
|