capdag 0.92.23685 → 0.94.24331
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 +431 -111
- package/capdag.test.js +493 -226
- package/{route-parser.js → machine-parser.js} +53 -23
- package/machine.pegjs +62 -0
- package/package.json +4 -4
- package/route.pegjs +0 -55
package/capdag.js
CHANGED
|
@@ -4257,7 +4257,7 @@ class PluginRepoServer {
|
|
|
4257
4257
|
}
|
|
4258
4258
|
|
|
4259
4259
|
// ============================================================================
|
|
4260
|
-
//
|
|
4260
|
+
// Machine Notation — compact, round-trippable DAG path identifiers
|
|
4261
4261
|
//
|
|
4262
4262
|
// Route notation describes capability transformation paths using bracket-
|
|
4263
4263
|
// delimited statements:
|
|
@@ -4268,18 +4268,24 @@ class PluginRepoServer {
|
|
|
4268
4268
|
// ============================================================================
|
|
4269
4269
|
|
|
4270
4270
|
/**
|
|
4271
|
-
* Error types for
|
|
4272
|
-
* Mirrors Rust
|
|
4271
|
+
* Error types for machine notation parsing.
|
|
4272
|
+
* Mirrors Rust MachineSyntaxError exactly.
|
|
4273
4273
|
*/
|
|
4274
|
-
class
|
|
4275
|
-
|
|
4274
|
+
class MachineSyntaxError extends Error {
|
|
4275
|
+
/**
|
|
4276
|
+
* @param {string} code - Error code from MachineSyntaxErrorCodes
|
|
4277
|
+
* @param {string} message - Human-readable error message
|
|
4278
|
+
* @param {Object|null} [location] - Source location { start: {offset,line,column}, end: {offset,line,column} }
|
|
4279
|
+
*/
|
|
4280
|
+
constructor(code, message, location) {
|
|
4276
4281
|
super(message);
|
|
4277
|
-
this.name = '
|
|
4282
|
+
this.name = 'MachineSyntaxError';
|
|
4278
4283
|
this.code = code;
|
|
4284
|
+
this.location = location || null;
|
|
4279
4285
|
}
|
|
4280
4286
|
}
|
|
4281
4287
|
|
|
4282
|
-
const
|
|
4288
|
+
const MachineSyntaxErrorCodes = {
|
|
4283
4289
|
EMPTY: 'Empty',
|
|
4284
4290
|
UNTERMINATED_STATEMENT: 'UnterminatedStatement',
|
|
4285
4291
|
INVALID_CAP_URN: 'InvalidCapUrn',
|
|
@@ -4300,9 +4306,9 @@ const RouteNotationErrorCodes = {
|
|
|
4300
4306
|
* media types into a target media type. The isLoop flag indicates
|
|
4301
4307
|
* ForEach semantics (the capability is applied to each item in a list).
|
|
4302
4308
|
*
|
|
4303
|
-
* Mirrors Rust
|
|
4309
|
+
* Mirrors Rust MachineEdge.
|
|
4304
4310
|
*/
|
|
4305
|
-
class
|
|
4311
|
+
class MachineEdge {
|
|
4306
4312
|
/**
|
|
4307
4313
|
* @param {MediaUrn[]} sources - Input media URN(s)
|
|
4308
4314
|
* @param {CapUrn} capUrn - The capability URN (edge label)
|
|
@@ -4326,7 +4332,7 @@ class RouteEdge {
|
|
|
4326
4332
|
* - Same isLoop flag
|
|
4327
4333
|
*
|
|
4328
4334
|
* Source order does not matter — fan-in sources are compared as sets.
|
|
4329
|
-
* Mirrors Rust
|
|
4335
|
+
* Mirrors Rust MachineEdge::is_equivalent.
|
|
4330
4336
|
*/
|
|
4331
4337
|
isEquivalent(other) {
|
|
4332
4338
|
if (this.isLoop !== other.isLoop) {
|
|
@@ -4370,7 +4376,7 @@ class RouteEdge {
|
|
|
4370
4376
|
|
|
4371
4377
|
/**
|
|
4372
4378
|
* Display string for this edge.
|
|
4373
|
-
* Mirrors Rust Display for
|
|
4379
|
+
* Mirrors Rust Display for MachineEdge.
|
|
4374
4380
|
*/
|
|
4375
4381
|
toString() {
|
|
4376
4382
|
const sources = this.sources.map(s => s.toString()).join(', ');
|
|
@@ -4380,7 +4386,7 @@ class RouteEdge {
|
|
|
4380
4386
|
}
|
|
4381
4387
|
|
|
4382
4388
|
/**
|
|
4383
|
-
* A route graph — the semantic model behind
|
|
4389
|
+
* A route graph — the semantic model behind machine notation.
|
|
4384
4390
|
*
|
|
4385
4391
|
* The graph is a collection of directed edges where each edge is a capability
|
|
4386
4392
|
* that transforms source media types into a target media type.
|
|
@@ -4389,11 +4395,11 @@ class RouteEdge {
|
|
|
4389
4395
|
* of ordering. Alias names used in the textual notation are not part of
|
|
4390
4396
|
* the graph model.
|
|
4391
4397
|
*
|
|
4392
|
-
* Mirrors Rust
|
|
4398
|
+
* Mirrors Rust Machine.
|
|
4393
4399
|
*/
|
|
4394
|
-
class
|
|
4400
|
+
class Machine {
|
|
4395
4401
|
/**
|
|
4396
|
-
* @param {
|
|
4402
|
+
* @param {MachineEdge[]} edges
|
|
4397
4403
|
*/
|
|
4398
4404
|
constructor(edges) {
|
|
4399
4405
|
this._edges = edges;
|
|
@@ -4401,25 +4407,25 @@ class RouteGraph {
|
|
|
4401
4407
|
|
|
4402
4408
|
/**
|
|
4403
4409
|
* Create an empty route graph.
|
|
4404
|
-
* @returns {
|
|
4410
|
+
* @returns {Machine}
|
|
4405
4411
|
*/
|
|
4406
4412
|
static empty() {
|
|
4407
|
-
return new
|
|
4413
|
+
return new Machine([]);
|
|
4408
4414
|
}
|
|
4409
4415
|
|
|
4410
4416
|
/**
|
|
4411
|
-
* Parse
|
|
4417
|
+
* Parse machine notation into a Machine.
|
|
4412
4418
|
* @param {string} input
|
|
4413
|
-
* @returns {
|
|
4414
|
-
* @throws {
|
|
4419
|
+
* @returns {Machine}
|
|
4420
|
+
* @throws {MachineSyntaxError}
|
|
4415
4421
|
*/
|
|
4416
4422
|
static fromString(input) {
|
|
4417
|
-
return
|
|
4423
|
+
return parseMachine(input);
|
|
4418
4424
|
}
|
|
4419
4425
|
|
|
4420
4426
|
/**
|
|
4421
4427
|
* Get the edges of this graph.
|
|
4422
|
-
* @returns {
|
|
4428
|
+
* @returns {MachineEdge[]}
|
|
4423
4429
|
*/
|
|
4424
4430
|
edges() {
|
|
4425
4431
|
return this._edges;
|
|
@@ -4445,10 +4451,10 @@ class RouteGraph {
|
|
|
4445
4451
|
* Check if two route graphs are semantically equivalent.
|
|
4446
4452
|
*
|
|
4447
4453
|
* Two graphs are equivalent if they have the same set of edges
|
|
4448
|
-
* (compared using
|
|
4454
|
+
* (compared using MachineEdge.isEquivalent). Edge ordering
|
|
4449
4455
|
* does not matter.
|
|
4450
4456
|
*
|
|
4451
|
-
* Mirrors Rust
|
|
4457
|
+
* Mirrors Rust Machine::is_equivalent.
|
|
4452
4458
|
*/
|
|
4453
4459
|
isEquivalent(other) {
|
|
4454
4460
|
if (this._edges.length !== other._edges.length) {
|
|
@@ -4480,7 +4486,7 @@ class RouteGraph {
|
|
|
4480
4486
|
* also produced as targets by any other edge. These are the "root"
|
|
4481
4487
|
* inputs to the graph.
|
|
4482
4488
|
*
|
|
4483
|
-
* Mirrors Rust
|
|
4489
|
+
* Mirrors Rust Machine::root_sources.
|
|
4484
4490
|
* @returns {MediaUrn[]}
|
|
4485
4491
|
*/
|
|
4486
4492
|
rootSources() {
|
|
@@ -4505,7 +4511,7 @@ class RouteGraph {
|
|
|
4505
4511
|
* Collect all unique target media URNs that are not consumed as sources
|
|
4506
4512
|
* by any other edge. These are the "leaf" outputs of the graph.
|
|
4507
4513
|
*
|
|
4508
|
-
* Mirrors Rust
|
|
4514
|
+
* Mirrors Rust Machine::leaf_targets.
|
|
4509
4515
|
* @returns {MediaUrn[]}
|
|
4510
4516
|
*/
|
|
4511
4517
|
leafTargets() {
|
|
@@ -4530,13 +4536,13 @@ class RouteGraph {
|
|
|
4530
4536
|
// =========================================================================
|
|
4531
4537
|
|
|
4532
4538
|
/**
|
|
4533
|
-
* Serialize this route graph to canonical one-line
|
|
4539
|
+
* Serialize this route graph to canonical one-line machine notation.
|
|
4534
4540
|
*
|
|
4535
4541
|
* The output is deterministic: same graph → same string.
|
|
4536
|
-
* Mirrors Rust
|
|
4542
|
+
* Mirrors Rust Machine::to_machine_notation.
|
|
4537
4543
|
* @returns {string}
|
|
4538
4544
|
*/
|
|
4539
|
-
|
|
4545
|
+
toMachineNotation() {
|
|
4540
4546
|
if (this._edges.length === 0) {
|
|
4541
4547
|
return '';
|
|
4542
4548
|
}
|
|
@@ -4587,11 +4593,11 @@ class RouteGraph {
|
|
|
4587
4593
|
}
|
|
4588
4594
|
|
|
4589
4595
|
/**
|
|
4590
|
-
* Serialize to multi-line
|
|
4591
|
-
* Mirrors Rust
|
|
4596
|
+
* Serialize to multi-line machine notation (one statement per line).
|
|
4597
|
+
* Mirrors Rust Machine::to_machine_notation_multiline.
|
|
4592
4598
|
* @returns {string}
|
|
4593
4599
|
*/
|
|
4594
|
-
|
|
4600
|
+
toMachineNotationMultiline() {
|
|
4595
4601
|
if (this._edges.length === 0) {
|
|
4596
4602
|
return '';
|
|
4597
4603
|
}
|
|
@@ -4647,7 +4653,7 @@ class RouteGraph {
|
|
|
4647
4653
|
* - nodeNames: Map<string, string> (media_urn_canonical → node_name)
|
|
4648
4654
|
* - edgeOrder: number[] (edge indices in canonical order)
|
|
4649
4655
|
*
|
|
4650
|
-
* Mirrors Rust
|
|
4656
|
+
* Mirrors Rust Machine::build_serialization_maps.
|
|
4651
4657
|
* @private
|
|
4652
4658
|
*/
|
|
4653
4659
|
_buildSerializationMaps() {
|
|
@@ -4714,15 +4720,87 @@ class RouteGraph {
|
|
|
4714
4720
|
return { aliases, nodeNames, edgeOrder };
|
|
4715
4721
|
}
|
|
4716
4722
|
|
|
4723
|
+
/**
|
|
4724
|
+
* Generate a Mermaid flowchart string from this machine graph.
|
|
4725
|
+
*
|
|
4726
|
+
* - Root sources: stadium-shaped nodes (rounded)
|
|
4727
|
+
* - Leaf targets: stadium-shaped nodes (rounded)
|
|
4728
|
+
* - Intermediate nodes: rectangular
|
|
4729
|
+
* - Edge labels: op= tag value (or full cap URN if no op)
|
|
4730
|
+
* - LOOP edges: dotted line style with "LOOP" prefix on label
|
|
4731
|
+
* - Node labels: derived MediaUrn type
|
|
4732
|
+
*
|
|
4733
|
+
* @returns {string} Mermaid flowchart definition
|
|
4734
|
+
*/
|
|
4735
|
+
toMermaid() {
|
|
4736
|
+
if (this._edges.length === 0) {
|
|
4737
|
+
return 'flowchart LR\n empty["(empty graph)"]';
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
const { aliases, nodeNames, edgeOrder } = this._buildSerializationMaps();
|
|
4741
|
+
const rootSourceSet = new Set(this.rootSources().map(s => s.toString()));
|
|
4742
|
+
const leafTargetSet = new Set(this.leafTargets().map(t => t.toString()));
|
|
4743
|
+
|
|
4744
|
+
const lines = ['flowchart LR'];
|
|
4745
|
+
|
|
4746
|
+
// Define node shapes based on role
|
|
4747
|
+
for (const [mediaKey, nodeName] of nodeNames) {
|
|
4748
|
+
// Escape special mermaid characters in the label
|
|
4749
|
+
const label = mediaKey.replace(/"/g, '#quot;');
|
|
4750
|
+
if (rootSourceSet.has(mediaKey)) {
|
|
4751
|
+
// Stadium shape for roots
|
|
4752
|
+
lines.push(` ${nodeName}([${label}])`);
|
|
4753
|
+
} else if (leafTargetSet.has(mediaKey)) {
|
|
4754
|
+
// Stadium shape for leaves
|
|
4755
|
+
lines.push(` ${nodeName}([${label}])`);
|
|
4756
|
+
} else {
|
|
4757
|
+
// Rectangle for intermediates
|
|
4758
|
+
lines.push(` ${nodeName}[${label}]`);
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
// Define edges
|
|
4763
|
+
for (const edgeIdx of edgeOrder) {
|
|
4764
|
+
const edge = this._edges[edgeIdx];
|
|
4765
|
+
// Find alias for this edge
|
|
4766
|
+
let edgeLabel = null;
|
|
4767
|
+
for (const [a, info] of aliases) {
|
|
4768
|
+
if (info.edgeIdx === edgeIdx) {
|
|
4769
|
+
edgeLabel = a;
|
|
4770
|
+
break;
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
const opTag = edge.capUrn.getTag('op');
|
|
4774
|
+
const label = opTag !== undefined ? opTag : edgeLabel;
|
|
4775
|
+
|
|
4776
|
+
const targetKey = edge.target.toString();
|
|
4777
|
+
const targetName = nodeNames.get(targetKey);
|
|
4778
|
+
|
|
4779
|
+
for (const src of edge.sources) {
|
|
4780
|
+
const srcKey = src.toString();
|
|
4781
|
+
const srcName = nodeNames.get(srcKey);
|
|
4782
|
+
|
|
4783
|
+
if (edge.isLoop) {
|
|
4784
|
+
// Dotted line for LOOP edges
|
|
4785
|
+
lines.push(` ${srcName} -. "LOOP ${label}" .-> ${targetName}`);
|
|
4786
|
+
} else {
|
|
4787
|
+
lines.push(` ${srcName} -- "${label}" --> ${targetName}`);
|
|
4788
|
+
}
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
return lines.join('\n');
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4717
4795
|
/**
|
|
4718
4796
|
* Display string for this graph.
|
|
4719
|
-
* Mirrors Rust Display for
|
|
4797
|
+
* Mirrors Rust Display for Machine.
|
|
4720
4798
|
*/
|
|
4721
4799
|
toString() {
|
|
4722
4800
|
if (this._edges.length === 0) {
|
|
4723
|
-
return '
|
|
4801
|
+
return 'Machine(empty)';
|
|
4724
4802
|
}
|
|
4725
|
-
return `
|
|
4803
|
+
return `Machine(${this._edges.length} edges)`;
|
|
4726
4804
|
}
|
|
4727
4805
|
}
|
|
4728
4806
|
|
|
@@ -4732,7 +4810,7 @@ class RouteGraph {
|
|
|
4732
4810
|
// ============================================================================
|
|
4733
4811
|
|
|
4734
4812
|
// Load the Peggy-generated parser
|
|
4735
|
-
const routeParser = require('./
|
|
4813
|
+
const routeParser = require('./machine-parser.js');
|
|
4736
4814
|
|
|
4737
4815
|
/**
|
|
4738
4816
|
* Assign a media URN to a node, or check consistency if already assigned.
|
|
@@ -4743,14 +4821,15 @@ const routeParser = require('./route-parser.js');
|
|
|
4743
4821
|
* Mirrors Rust assign_or_check_node.
|
|
4744
4822
|
* @private
|
|
4745
4823
|
*/
|
|
4746
|
-
function assignOrCheckNode(node, mediaUrn, nodeMedia, position) {
|
|
4824
|
+
function assignOrCheckNode(node, mediaUrn, nodeMedia, position, location) {
|
|
4747
4825
|
const existing = nodeMedia.get(node);
|
|
4748
4826
|
if (existing !== undefined) {
|
|
4749
4827
|
const compatible = existing.isComparable(mediaUrn);
|
|
4750
4828
|
if (!compatible) {
|
|
4751
|
-
throw new
|
|
4752
|
-
|
|
4753
|
-
`invalid wiring at statement ${position}: node '${node}' has conflicting media types: existing '${existing}', new '${mediaUrn}'
|
|
4829
|
+
throw new MachineSyntaxError(
|
|
4830
|
+
MachineSyntaxErrorCodes.INVALID_WIRING,
|
|
4831
|
+
`invalid wiring at statement ${position}: node '${node}' has conflicting media types: existing '${existing}', new '${mediaUrn}'`,
|
|
4832
|
+
location
|
|
4754
4833
|
);
|
|
4755
4834
|
}
|
|
4756
4835
|
} else {
|
|
@@ -4759,25 +4838,20 @@ function assignOrCheckNode(node, mediaUrn, nodeMedia, position) {
|
|
|
4759
4838
|
}
|
|
4760
4839
|
|
|
4761
4840
|
/**
|
|
4762
|
-
*
|
|
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.
|
|
4841
|
+
* Internal: run the 4-phase parse pipeline on machine notation input.
|
|
4842
|
+
* Returns { machine, statements, aliasMap, nodeMedia } for full introspection.
|
|
4770
4843
|
*
|
|
4771
4844
|
* @param {string} input - Route notation string
|
|
4772
|
-
* @returns {
|
|
4773
|
-
* @throws {
|
|
4845
|
+
* @returns {{ machine: Machine, statements: Object[], aliasMap: Map, nodeMedia: Map }}
|
|
4846
|
+
* @throws {MachineSyntaxError}
|
|
4847
|
+
* @private
|
|
4774
4848
|
*/
|
|
4775
|
-
function
|
|
4849
|
+
function _parseMachineInternal(input) {
|
|
4776
4850
|
const trimmed = input.trim();
|
|
4777
4851
|
if (trimmed.length === 0) {
|
|
4778
|
-
throw new
|
|
4779
|
-
|
|
4780
|
-
'
|
|
4852
|
+
throw new MachineSyntaxError(
|
|
4853
|
+
MachineSyntaxErrorCodes.EMPTY,
|
|
4854
|
+
'machine notation is empty'
|
|
4781
4855
|
);
|
|
4782
4856
|
}
|
|
4783
4857
|
|
|
@@ -4786,15 +4860,18 @@ function parseRouteNotation(input) {
|
|
|
4786
4860
|
try {
|
|
4787
4861
|
stmts = routeParser.parse(trimmed);
|
|
4788
4862
|
} catch (e) {
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4863
|
+
// Peggy SyntaxError has .location — propagate it
|
|
4864
|
+
const loc = e.location || null;
|
|
4865
|
+
throw new MachineSyntaxError(
|
|
4866
|
+
MachineSyntaxErrorCodes.PARSE_ERROR,
|
|
4867
|
+
`parse error: ${e.message}`,
|
|
4868
|
+
loc
|
|
4792
4869
|
);
|
|
4793
4870
|
}
|
|
4794
4871
|
|
|
4795
4872
|
// Phase 2: Separate headers and wirings (already done by grammar actions)
|
|
4796
|
-
const headers = [];
|
|
4797
|
-
const wirings = [];
|
|
4873
|
+
const headers = [];
|
|
4874
|
+
const wirings = [];
|
|
4798
4875
|
|
|
4799
4876
|
for (let i = 0; i < stmts.length; i++) {
|
|
4800
4877
|
const stmt = stmts[i];
|
|
@@ -4804,12 +4881,20 @@ function parseRouteNotation(input) {
|
|
|
4804
4881
|
try {
|
|
4805
4882
|
capUrn = CapUrn.fromString(stmt.capUrn);
|
|
4806
4883
|
} catch (e) {
|
|
4807
|
-
throw new
|
|
4808
|
-
|
|
4809
|
-
`invalid cap URN in header '${stmt.alias}': ${e.message}
|
|
4884
|
+
throw new MachineSyntaxError(
|
|
4885
|
+
MachineSyntaxErrorCodes.INVALID_CAP_URN,
|
|
4886
|
+
`invalid cap URN in header '${stmt.alias}': ${e.message}`,
|
|
4887
|
+
stmt.capUrnLocation || stmt.location
|
|
4810
4888
|
);
|
|
4811
4889
|
}
|
|
4812
|
-
headers.push({
|
|
4890
|
+
headers.push({
|
|
4891
|
+
alias: stmt.alias,
|
|
4892
|
+
capUrn,
|
|
4893
|
+
position: i,
|
|
4894
|
+
location: stmt.location,
|
|
4895
|
+
aliasLocation: stmt.aliasLocation,
|
|
4896
|
+
capUrnLocation: stmt.capUrnLocation,
|
|
4897
|
+
});
|
|
4813
4898
|
} else if (stmt.type === 'wiring') {
|
|
4814
4899
|
wirings.push({
|
|
4815
4900
|
sources: stmt.sources,
|
|
@@ -4817,28 +4902,40 @@ function parseRouteNotation(input) {
|
|
|
4817
4902
|
target: stmt.target,
|
|
4818
4903
|
isLoop: stmt.isLoop,
|
|
4819
4904
|
position: i,
|
|
4905
|
+
location: stmt.location,
|
|
4906
|
+
sourceLocations: stmt.sourceLocations,
|
|
4907
|
+
capAliasLocation: stmt.capAliasLocation,
|
|
4908
|
+
targetLocation: stmt.targetLocation,
|
|
4820
4909
|
});
|
|
4821
4910
|
}
|
|
4822
4911
|
}
|
|
4823
4912
|
|
|
4824
4913
|
// Phase 3: Build alias → CapUrn map, checking for duplicates
|
|
4825
|
-
const aliasMap = new Map();
|
|
4914
|
+
const aliasMap = new Map();
|
|
4826
4915
|
for (const header of headers) {
|
|
4827
4916
|
if (aliasMap.has(header.alias)) {
|
|
4828
4917
|
const firstPos = aliasMap.get(header.alias).position;
|
|
4829
|
-
throw new
|
|
4830
|
-
|
|
4831
|
-
`duplicate alias '${header.alias}' (first defined at statement ${firstPos})
|
|
4918
|
+
throw new MachineSyntaxError(
|
|
4919
|
+
MachineSyntaxErrorCodes.DUPLICATE_ALIAS,
|
|
4920
|
+
`duplicate alias '${header.alias}' (first defined at statement ${firstPos})`,
|
|
4921
|
+
header.aliasLocation || header.location
|
|
4832
4922
|
);
|
|
4833
4923
|
}
|
|
4834
|
-
aliasMap.set(header.alias, {
|
|
4924
|
+
aliasMap.set(header.alias, {
|
|
4925
|
+
capUrn: header.capUrn,
|
|
4926
|
+
position: header.position,
|
|
4927
|
+
location: header.location,
|
|
4928
|
+
aliasLocation: header.aliasLocation,
|
|
4929
|
+
capUrnLocation: header.capUrnLocation,
|
|
4930
|
+
});
|
|
4835
4931
|
}
|
|
4836
4932
|
|
|
4837
|
-
// Phase 4: Resolve wirings into
|
|
4933
|
+
// Phase 4: Resolve wirings into MachineEdges
|
|
4838
4934
|
if (wirings.length === 0 && headers.length > 0) {
|
|
4839
|
-
throw new
|
|
4840
|
-
|
|
4841
|
-
'route has headers but no wirings — define at least one edge'
|
|
4935
|
+
throw new MachineSyntaxError(
|
|
4936
|
+
MachineSyntaxErrorCodes.NO_EDGES,
|
|
4937
|
+
'route has headers but no wirings — define at least one edge',
|
|
4938
|
+
headers[headers.length - 1].location
|
|
4842
4939
|
);
|
|
4843
4940
|
}
|
|
4844
4941
|
|
|
@@ -4849,26 +4946,30 @@ function parseRouteNotation(input) {
|
|
|
4849
4946
|
// Look up the cap alias
|
|
4850
4947
|
const aliasEntry = aliasMap.get(wiring.capAlias);
|
|
4851
4948
|
if (!aliasEntry) {
|
|
4852
|
-
throw new
|
|
4853
|
-
|
|
4854
|
-
`wiring references undefined alias '${wiring.capAlias}'
|
|
4949
|
+
throw new MachineSyntaxError(
|
|
4950
|
+
MachineSyntaxErrorCodes.UNDEFINED_ALIAS,
|
|
4951
|
+
`wiring references undefined alias '${wiring.capAlias}'`,
|
|
4952
|
+
wiring.capAliasLocation || wiring.location
|
|
4855
4953
|
);
|
|
4856
4954
|
}
|
|
4857
4955
|
const capUrn = aliasEntry.capUrn;
|
|
4858
4956
|
|
|
4859
4957
|
// Check node-alias collisions
|
|
4860
|
-
for (
|
|
4958
|
+
for (let si = 0; si < wiring.sources.length; si++) {
|
|
4959
|
+
const src = wiring.sources[si];
|
|
4861
4960
|
if (aliasMap.has(src)) {
|
|
4862
|
-
throw new
|
|
4863
|
-
|
|
4864
|
-
`node name '${src}' collides with cap alias '${src}'
|
|
4961
|
+
throw new MachineSyntaxError(
|
|
4962
|
+
MachineSyntaxErrorCodes.NODE_ALIAS_COLLISION,
|
|
4963
|
+
`node name '${src}' collides with cap alias '${src}'`,
|
|
4964
|
+
wiring.sourceLocations ? wiring.sourceLocations[si] : wiring.location
|
|
4865
4965
|
);
|
|
4866
4966
|
}
|
|
4867
4967
|
}
|
|
4868
4968
|
if (aliasMap.has(wiring.target)) {
|
|
4869
|
-
throw new
|
|
4870
|
-
|
|
4871
|
-
`node name '${wiring.target}' collides with cap alias '${wiring.target}'
|
|
4969
|
+
throw new MachineSyntaxError(
|
|
4970
|
+
MachineSyntaxErrorCodes.NODE_ALIAS_COLLISION,
|
|
4971
|
+
`node name '${wiring.target}' collides with cap alias '${wiring.target}'`,
|
|
4972
|
+
wiring.targetLocation || wiring.location
|
|
4872
4973
|
);
|
|
4873
4974
|
}
|
|
4874
4975
|
|
|
@@ -4877,9 +4978,10 @@ function parseRouteNotation(input) {
|
|
|
4877
4978
|
try {
|
|
4878
4979
|
capInMedia = capUrn.inMediaUrn();
|
|
4879
4980
|
} catch (e) {
|
|
4880
|
-
throw new
|
|
4881
|
-
|
|
4882
|
-
`invalid media URN in cap '${wiring.capAlias}': in= spec: ${e.message}
|
|
4981
|
+
throw new MachineSyntaxError(
|
|
4982
|
+
MachineSyntaxErrorCodes.INVALID_MEDIA_URN,
|
|
4983
|
+
`invalid media URN in cap '${wiring.capAlias}': in= spec: ${e.message}`,
|
|
4984
|
+
aliasEntry.capUrnLocation || wiring.location
|
|
4883
4985
|
);
|
|
4884
4986
|
}
|
|
4885
4987
|
|
|
@@ -4887,9 +4989,10 @@ function parseRouteNotation(input) {
|
|
|
4887
4989
|
try {
|
|
4888
4990
|
capOutMedia = capUrn.outMediaUrn();
|
|
4889
4991
|
} catch (e) {
|
|
4890
|
-
throw new
|
|
4891
|
-
|
|
4892
|
-
`invalid media URN in cap '${wiring.capAlias}': out= spec: ${e.message}
|
|
4992
|
+
throw new MachineSyntaxError(
|
|
4993
|
+
MachineSyntaxErrorCodes.INVALID_MEDIA_URN,
|
|
4994
|
+
`invalid media URN in cap '${wiring.capAlias}': out= spec: ${e.message}`,
|
|
4995
|
+
aliasEntry.capUrnLocation || wiring.location
|
|
4893
4996
|
);
|
|
4894
4997
|
}
|
|
4895
4998
|
|
|
@@ -4899,7 +5002,8 @@ function parseRouteNotation(input) {
|
|
|
4899
5002
|
const src = wiring.sources[i];
|
|
4900
5003
|
if (i === 0) {
|
|
4901
5004
|
// Primary source: use cap's in= spec
|
|
4902
|
-
assignOrCheckNode(src, capInMedia, nodeMedia, wiring.position
|
|
5005
|
+
assignOrCheckNode(src, capInMedia, nodeMedia, wiring.position,
|
|
5006
|
+
wiring.sourceLocations ? wiring.sourceLocations[i] : wiring.location);
|
|
4903
5007
|
sourceUrns.push(capInMedia);
|
|
4904
5008
|
} else {
|
|
4905
5009
|
// Secondary source (fan-in): use existing type if assigned,
|
|
@@ -4915,26 +5019,64 @@ function parseRouteNotation(input) {
|
|
|
4915
5019
|
}
|
|
4916
5020
|
|
|
4917
5021
|
// Assign target media URN
|
|
4918
|
-
assignOrCheckNode(wiring.target, capOutMedia, nodeMedia, wiring.position
|
|
5022
|
+
assignOrCheckNode(wiring.target, capOutMedia, nodeMedia, wiring.position,
|
|
5023
|
+
wiring.targetLocation || wiring.location);
|
|
4919
5024
|
|
|
4920
|
-
edges.push(new
|
|
5025
|
+
edges.push(new MachineEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
|
|
4921
5026
|
}
|
|
4922
5027
|
|
|
4923
|
-
return
|
|
5028
|
+
return {
|
|
5029
|
+
machine: new Machine(edges),
|
|
5030
|
+
statements: stmts,
|
|
5031
|
+
aliasMap,
|
|
5032
|
+
nodeMedia,
|
|
5033
|
+
};
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
/**
|
|
5037
|
+
* Parse machine notation into a Machine.
|
|
5038
|
+
*
|
|
5039
|
+
* Uses the Peggy-generated PEG parser to parse the input, then resolves
|
|
5040
|
+
* cap URNs and derives media URNs from cap in/out specs.
|
|
5041
|
+
*
|
|
5042
|
+
* Fails hard — no fallbacks, no guessing, no recovery.
|
|
5043
|
+
*
|
|
5044
|
+
* Mirrors Rust parse_machine exactly.
|
|
5045
|
+
*
|
|
5046
|
+
* @param {string} input - Route notation string
|
|
5047
|
+
* @returns {Machine}
|
|
5048
|
+
* @throws {MachineSyntaxError}
|
|
5049
|
+
*/
|
|
5050
|
+
function parseMachine(input) {
|
|
5051
|
+
return _parseMachineInternal(input).machine;
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
/**
|
|
5055
|
+
* Parse machine notation and return both the Machine and the raw AST with locations.
|
|
5056
|
+
*
|
|
5057
|
+
* Use this for LSP tooling — the statements array contains full position information
|
|
5058
|
+
* for every element (aliases, cap URNs, sources, targets).
|
|
5059
|
+
*
|
|
5060
|
+
* @param {string} input - Route notation string
|
|
5061
|
+
* @returns {{ machine: Machine, statements: Object[], aliasMap: Map, nodeMedia: Map }}
|
|
5062
|
+
* @throws {MachineSyntaxError}
|
|
5063
|
+
*/
|
|
5064
|
+
function parseMachineWithAST(input) {
|
|
5065
|
+
return _parseMachineInternal(input);
|
|
4924
5066
|
}
|
|
4925
5067
|
|
|
4926
5068
|
// ============================================================================
|
|
4927
|
-
//
|
|
5069
|
+
// MachineBuilder — programmatic path construction
|
|
4928
5070
|
// ============================================================================
|
|
4929
5071
|
|
|
4930
5072
|
/**
|
|
4931
|
-
* Builder for constructing
|
|
5073
|
+
* Builder for constructing Machines programmatically.
|
|
4932
5074
|
*
|
|
4933
5075
|
* Provides a fluent API for building route graphs without writing
|
|
4934
|
-
*
|
|
5076
|
+
* machine notation strings. Useful for constructing paths from graph
|
|
4935
5077
|
* exploration (e.g., selecting paths in the UI).
|
|
4936
5078
|
*/
|
|
4937
|
-
class
|
|
5079
|
+
class MachineBuilder {
|
|
4938
5080
|
constructor() {
|
|
4939
5081
|
this._edges = [];
|
|
4940
5082
|
}
|
|
@@ -4945,13 +5087,13 @@ class RouteGraphBuilder {
|
|
|
4945
5087
|
* @param {string} capUrnStr - Cap URN string
|
|
4946
5088
|
* @param {string} targetUrn - Target media URN string
|
|
4947
5089
|
* @param {boolean} [isLoop=false] - Whether this edge has ForEach semantics
|
|
4948
|
-
* @returns {
|
|
5090
|
+
* @returns {MachineBuilder} this (for chaining)
|
|
4949
5091
|
*/
|
|
4950
5092
|
addEdge(sourceUrns, capUrnStr, targetUrn, isLoop = false) {
|
|
4951
5093
|
const sources = sourceUrns.map(s => MediaUrn.fromString(s));
|
|
4952
5094
|
const capUrn = CapUrn.fromString(capUrnStr);
|
|
4953
5095
|
const target = MediaUrn.fromString(targetUrn);
|
|
4954
|
-
this._edges.push(new
|
|
5096
|
+
this._edges.push(new MachineEdge(sources, capUrn, target, isLoop));
|
|
4955
5097
|
return this;
|
|
4956
5098
|
}
|
|
4957
5099
|
|
|
@@ -4959,26 +5101,199 @@ class RouteGraphBuilder {
|
|
|
4959
5101
|
* Add a linear chain of edges from CapGraphEdge[] (from CapGraph.findAllPaths).
|
|
4960
5102
|
*
|
|
4961
5103
|
* Each CapGraphEdge has fromUrn, toUrn, and cap (with cap.urn).
|
|
4962
|
-
* This converts the path into a series of
|
|
5104
|
+
* This converts the path into a series of MachineEdges.
|
|
4963
5105
|
*
|
|
4964
5106
|
* @param {CapGraphEdge[]} capGraphEdges - Array of CapGraphEdge from pathfinding
|
|
4965
|
-
* @returns {
|
|
5107
|
+
* @returns {MachineBuilder} this (for chaining)
|
|
4966
5108
|
*/
|
|
4967
5109
|
addCapGraphPath(capGraphEdges) {
|
|
4968
5110
|
for (const edge of capGraphEdges) {
|
|
4969
5111
|
const source = MediaUrn.fromString(edge.fromUrn);
|
|
4970
5112
|
const target = MediaUrn.fromString(edge.toUrn);
|
|
4971
|
-
this._edges.push(new
|
|
5113
|
+
this._edges.push(new MachineEdge([source], edge.cap.urn, target, false));
|
|
4972
5114
|
}
|
|
4973
5115
|
return this;
|
|
4974
5116
|
}
|
|
4975
5117
|
|
|
4976
5118
|
/**
|
|
4977
|
-
* Build the
|
|
4978
|
-
* @returns {
|
|
5119
|
+
* Build the Machine from the accumulated edges.
|
|
5120
|
+
* @returns {Machine}
|
|
4979
5121
|
*/
|
|
4980
5122
|
build() {
|
|
4981
|
-
return new
|
|
5123
|
+
return new Machine([...this._edges]);
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
|
|
5127
|
+
// ============================================================================
|
|
5128
|
+
// Cap & Media Registry Client
|
|
5129
|
+
// Fetches and caches capability and media registries from capdag.com
|
|
5130
|
+
// ============================================================================
|
|
5131
|
+
|
|
5132
|
+
/**
|
|
5133
|
+
* A capability entry from the registry.
|
|
5134
|
+
* Matches the denormalized view format from capdag.com /api/capabilities.
|
|
5135
|
+
*/
|
|
5136
|
+
class CapRegistryEntry {
|
|
5137
|
+
constructor(data) {
|
|
5138
|
+
this.urn = data.urn;
|
|
5139
|
+
this.title = data.title || '';
|
|
5140
|
+
this.command = data.command || '';
|
|
5141
|
+
this.description = data.cap_description || '';
|
|
5142
|
+
this.args = data.args || [];
|
|
5143
|
+
this.output = data.output || null;
|
|
5144
|
+
this.mediaSpecs = data.media_specs || [];
|
|
5145
|
+
this.urnTags = data.urn_tags || {};
|
|
5146
|
+
this.inSpec = data.in_spec || '';
|
|
5147
|
+
this.outSpec = data.out_spec || '';
|
|
5148
|
+
this.inMediaTitle = data.in_media_title || '';
|
|
5149
|
+
this.outMediaTitle = data.out_media_title || '';
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
|
|
5153
|
+
/**
|
|
5154
|
+
* A media spec entry from the registry.
|
|
5155
|
+
* Matches the media lookup format from capdag.com /media:*.
|
|
5156
|
+
*/
|
|
5157
|
+
class MediaRegistryEntry {
|
|
5158
|
+
constructor(data) {
|
|
5159
|
+
this.urn = data.urn;
|
|
5160
|
+
this.title = data.title || '';
|
|
5161
|
+
this.mediaType = data.media_type || '';
|
|
5162
|
+
this.description = data.description || '';
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
|
|
5166
|
+
/**
|
|
5167
|
+
* Client for fetching and caching capability and media registries from capdag.com.
|
|
5168
|
+
*
|
|
5169
|
+
* Uses a time-based cache with configurable TTL. All methods are async.
|
|
5170
|
+
* Fails hard on network errors — no silent degradation.
|
|
5171
|
+
*/
|
|
5172
|
+
class CapRegistryClient {
|
|
5173
|
+
/**
|
|
5174
|
+
* @param {string} [baseUrl='https://capdag.com'] - Registry base URL
|
|
5175
|
+
* @param {number} [cacheTtlSeconds=300] - Cache TTL in seconds
|
|
5176
|
+
*/
|
|
5177
|
+
constructor(baseUrl = 'https://capdag.com', cacheTtlSeconds = 300) {
|
|
5178
|
+
this._baseUrl = baseUrl.replace(/\/$/, '');
|
|
5179
|
+
this._cacheTtl = cacheTtlSeconds * 1000;
|
|
5180
|
+
this._capCache = null; // { entries: CapRegistryEntry[], fetchedAt: number }
|
|
5181
|
+
this._mediaCache = new Map(); // media_urn_string → { entry: MediaRegistryEntry, fetchedAt: number }
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
/**
|
|
5185
|
+
* Fetch all capabilities from the registry (cached).
|
|
5186
|
+
* @returns {Promise<CapRegistryEntry[]>}
|
|
5187
|
+
*/
|
|
5188
|
+
async fetchCapabilities() {
|
|
5189
|
+
if (this._capCache && (Date.now() - this._capCache.fetchedAt) < this._cacheTtl) {
|
|
5190
|
+
return this._capCache.entries;
|
|
5191
|
+
}
|
|
5192
|
+
|
|
5193
|
+
const response = await fetch(`${this._baseUrl}/api/capabilities`);
|
|
5194
|
+
if (!response.ok) {
|
|
5195
|
+
throw new Error(`Cap registry request failed: HTTP ${response.status} from ${this._baseUrl}/api/capabilities`);
|
|
5196
|
+
}
|
|
5197
|
+
|
|
5198
|
+
const data = await response.json();
|
|
5199
|
+
if (!Array.isArray(data)) {
|
|
5200
|
+
throw new Error(`Invalid cap registry response: expected array, got ${typeof data}`);
|
|
5201
|
+
}
|
|
5202
|
+
|
|
5203
|
+
const entries = data.map(d => new CapRegistryEntry(d));
|
|
5204
|
+
this._capCache = { entries, fetchedAt: Date.now() };
|
|
5205
|
+
return entries;
|
|
5206
|
+
}
|
|
5207
|
+
|
|
5208
|
+
/**
|
|
5209
|
+
* Lookup a single capability by URN.
|
|
5210
|
+
* Uses the capabilities cache if available, otherwise falls back to direct lookup.
|
|
5211
|
+
* @param {string} capUrnStr - Cap URN string
|
|
5212
|
+
* @returns {Promise<CapRegistryEntry|null>}
|
|
5213
|
+
*/
|
|
5214
|
+
async lookupCap(capUrnStr) {
|
|
5215
|
+
// Try cache first
|
|
5216
|
+
if (this._capCache && (Date.now() - this._capCache.fetchedAt) < this._cacheTtl) {
|
|
5217
|
+
const found = this._capCache.entries.find(e => e.urn === capUrnStr);
|
|
5218
|
+
if (found) return found;
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5221
|
+
// Direct lookup
|
|
5222
|
+
const encoded = encodeURIComponent(capUrnStr);
|
|
5223
|
+
const response = await fetch(`${this._baseUrl}/${encoded}`);
|
|
5224
|
+
if (response.status === 404) {
|
|
5225
|
+
return null;
|
|
5226
|
+
}
|
|
5227
|
+
if (!response.ok) {
|
|
5228
|
+
throw new Error(`Cap lookup failed: HTTP ${response.status} for ${capUrnStr}`);
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
const data = await response.json();
|
|
5232
|
+
return new CapRegistryEntry(data);
|
|
5233
|
+
}
|
|
5234
|
+
|
|
5235
|
+
/**
|
|
5236
|
+
* Lookup a single media spec by URN.
|
|
5237
|
+
* @param {string} mediaUrnStr - Media URN string
|
|
5238
|
+
* @returns {Promise<MediaRegistryEntry|null>}
|
|
5239
|
+
*/
|
|
5240
|
+
async lookupMedia(mediaUrnStr) {
|
|
5241
|
+
// Check cache
|
|
5242
|
+
const cached = this._mediaCache.get(mediaUrnStr);
|
|
5243
|
+
if (cached && (Date.now() - cached.fetchedAt) < this._cacheTtl) {
|
|
5244
|
+
return cached.entry;
|
|
5245
|
+
}
|
|
5246
|
+
|
|
5247
|
+
const encoded = encodeURIComponent(mediaUrnStr);
|
|
5248
|
+
const response = await fetch(`${this._baseUrl}/${encoded}`);
|
|
5249
|
+
if (response.status === 404) {
|
|
5250
|
+
return null;
|
|
5251
|
+
}
|
|
5252
|
+
if (!response.ok) {
|
|
5253
|
+
throw new Error(`Media lookup failed: HTTP ${response.status} for ${mediaUrnStr}`);
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
const data = await response.json();
|
|
5257
|
+
const entry = new MediaRegistryEntry(data);
|
|
5258
|
+
this._mediaCache.set(mediaUrnStr, { entry, fetchedAt: Date.now() });
|
|
5259
|
+
return entry;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
/**
|
|
5263
|
+
* Get all known media URNs from cached capabilities (in and out specs).
|
|
5264
|
+
* Fetches capabilities if not cached.
|
|
5265
|
+
* @returns {Promise<string[]>}
|
|
5266
|
+
*/
|
|
5267
|
+
async getKnownMediaUrns() {
|
|
5268
|
+
const caps = await this.fetchCapabilities();
|
|
5269
|
+
const urns = new Set();
|
|
5270
|
+
for (const cap of caps) {
|
|
5271
|
+
if (cap.inSpec) urns.add(cap.inSpec);
|
|
5272
|
+
if (cap.outSpec) urns.add(cap.outSpec);
|
|
5273
|
+
}
|
|
5274
|
+
return Array.from(urns).sort();
|
|
5275
|
+
}
|
|
5276
|
+
|
|
5277
|
+
/**
|
|
5278
|
+
* Get all known op= tag values from cached capabilities.
|
|
5279
|
+
* @returns {Promise<string[]>}
|
|
5280
|
+
*/
|
|
5281
|
+
async getKnownOps() {
|
|
5282
|
+
const caps = await this.fetchCapabilities();
|
|
5283
|
+
const ops = new Set();
|
|
5284
|
+
for (const cap of caps) {
|
|
5285
|
+
const op = cap.urnTags && cap.urnTags.op;
|
|
5286
|
+
if (op) ops.add(op);
|
|
5287
|
+
}
|
|
5288
|
+
return Array.from(ops).sort();
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
/**
|
|
5292
|
+
* Invalidate all caches. Next call to any method will fetch fresh data.
|
|
5293
|
+
*/
|
|
5294
|
+
invalidate() {
|
|
5295
|
+
this._capCache = null;
|
|
5296
|
+
this._mediaCache.clear();
|
|
4982
5297
|
}
|
|
4983
5298
|
}
|
|
4984
5299
|
|
|
@@ -5101,10 +5416,15 @@ module.exports = {
|
|
|
5101
5416
|
PluginRepoClient,
|
|
5102
5417
|
PluginRepoServer,
|
|
5103
5418
|
// Route notation
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5419
|
+
MachineSyntaxError,
|
|
5420
|
+
MachineSyntaxErrorCodes,
|
|
5421
|
+
MachineEdge,
|
|
5422
|
+
Machine,
|
|
5423
|
+
MachineBuilder,
|
|
5424
|
+
parseMachine,
|
|
5425
|
+
parseMachineWithAST,
|
|
5426
|
+
// Cap & Media Registry
|
|
5427
|
+
CapRegistryEntry,
|
|
5428
|
+
MediaRegistryEntry,
|
|
5429
|
+
CapRegistryClient,
|
|
5110
5430
|
};
|