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 CHANGED
@@ -4257,7 +4257,7 @@ class PluginRepoServer {
4257
4257
  }
4258
4258
 
4259
4259
  // ============================================================================
4260
- // Route Notation — compact, round-trippable DAG path identifiers
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 route notation parsing.
4272
- * Mirrors Rust RouteNotationError exactly.
4271
+ * Error types for machine notation parsing.
4272
+ * Mirrors Rust MachineSyntaxError exactly.
4273
4273
  */
4274
- class RouteNotationError extends Error {
4275
- constructor(code, message) {
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 = 'RouteNotationError';
4282
+ this.name = 'MachineSyntaxError';
4278
4283
  this.code = code;
4284
+ this.location = location || null;
4279
4285
  }
4280
4286
  }
4281
4287
 
4282
- const RouteNotationErrorCodes = {
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 RouteEdge.
4309
+ * Mirrors Rust MachineEdge.
4304
4310
  */
4305
- class RouteEdge {
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 RouteEdge::is_equivalent.
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 RouteEdge.
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 route notation.
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 RouteGraph.
4398
+ * Mirrors Rust Machine.
4393
4399
  */
4394
- class RouteGraph {
4400
+ class Machine {
4395
4401
  /**
4396
- * @param {RouteEdge[]} edges
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 {RouteGraph}
4410
+ * @returns {Machine}
4405
4411
  */
4406
4412
  static empty() {
4407
- return new RouteGraph([]);
4413
+ return new Machine([]);
4408
4414
  }
4409
4415
 
4410
4416
  /**
4411
- * Parse route notation into a RouteGraph.
4417
+ * Parse machine notation into a Machine.
4412
4418
  * @param {string} input
4413
- * @returns {RouteGraph}
4414
- * @throws {RouteNotationError}
4419
+ * @returns {Machine}
4420
+ * @throws {MachineSyntaxError}
4415
4421
  */
4416
4422
  static fromString(input) {
4417
- return parseRouteNotation(input);
4423
+ return parseMachine(input);
4418
4424
  }
4419
4425
 
4420
4426
  /**
4421
4427
  * Get the edges of this graph.
4422
- * @returns {RouteEdge[]}
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 RouteEdge.isEquivalent). Edge ordering
4454
+ * (compared using MachineEdge.isEquivalent). Edge ordering
4449
4455
  * does not matter.
4450
4456
  *
4451
- * Mirrors Rust RouteGraph::is_equivalent.
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 RouteGraph::root_sources.
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 RouteGraph::leaf_targets.
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 route notation.
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 RouteGraph::to_route_notation.
4542
+ * Mirrors Rust Machine::to_machine_notation.
4537
4543
  * @returns {string}
4538
4544
  */
4539
- toRouteNotation() {
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 route notation (one statement per line).
4591
- * Mirrors Rust RouteGraph::to_route_notation_multiline.
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
- toRouteNotationMultiline() {
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 RouteGraph::build_serialization_maps.
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 RouteGraph.
4797
+ * Mirrors Rust Display for Machine.
4720
4798
  */
4721
4799
  toString() {
4722
4800
  if (this._edges.length === 0) {
4723
- return 'RouteGraph(empty)';
4801
+ return 'Machine(empty)';
4724
4802
  }
4725
- return `RouteGraph(${this._edges.length} edges)`;
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('./route-parser.js');
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 RouteNotationError(
4752
- RouteNotationErrorCodes.INVALID_WIRING,
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
- * 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.
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 {RouteGraph}
4773
- * @throws {RouteNotationError}
4845
+ * @returns {{ machine: Machine, statements: Object[], aliasMap: Map, nodeMedia: Map }}
4846
+ * @throws {MachineSyntaxError}
4847
+ * @private
4774
4848
  */
4775
- function parseRouteNotation(input) {
4849
+ function _parseMachineInternal(input) {
4776
4850
  const trimmed = input.trim();
4777
4851
  if (trimmed.length === 0) {
4778
- throw new RouteNotationError(
4779
- RouteNotationErrorCodes.EMPTY,
4780
- 'route notation is empty'
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
- throw new RouteNotationError(
4790
- RouteNotationErrorCodes.PARSE_ERROR,
4791
- `parse error: ${e.message}`
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 = []; // { alias, capUrn, position }
4797
- const wirings = []; // { sources, capAlias, target, isLoop, position }
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 RouteNotationError(
4808
- RouteNotationErrorCodes.INVALID_CAP_URN,
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({ alias: stmt.alias, capUrn, position: i });
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(); // alias → { capUrn, position }
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 RouteNotationError(
4830
- RouteNotationErrorCodes.DUPLICATE_ALIAS,
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, { capUrn: header.capUrn, position: header.position });
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 RouteEdges
4933
+ // Phase 4: Resolve wirings into MachineEdges
4838
4934
  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'
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 RouteNotationError(
4853
- RouteNotationErrorCodes.UNDEFINED_ALIAS,
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 (const src of wiring.sources) {
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 RouteNotationError(
4863
- RouteNotationErrorCodes.NODE_ALIAS_COLLISION,
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 RouteNotationError(
4870
- RouteNotationErrorCodes.NODE_ALIAS_COLLISION,
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 RouteNotationError(
4881
- RouteNotationErrorCodes.INVALID_MEDIA_URN,
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 RouteNotationError(
4891
- RouteNotationErrorCodes.INVALID_MEDIA_URN,
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 RouteEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
5025
+ edges.push(new MachineEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
4921
5026
  }
4922
5027
 
4923
- return new RouteGraph(edges);
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
- // RouteGraphBuilder — programmatic path construction
5069
+ // MachineBuilder — programmatic path construction
4928
5070
  // ============================================================================
4929
5071
 
4930
5072
  /**
4931
- * Builder for constructing RouteGraphs programmatically.
5073
+ * Builder for constructing Machines programmatically.
4932
5074
  *
4933
5075
  * Provides a fluent API for building route graphs without writing
4934
- * route notation strings. Useful for constructing paths from graph
5076
+ * machine notation strings. Useful for constructing paths from graph
4935
5077
  * exploration (e.g., selecting paths in the UI).
4936
5078
  */
4937
- class RouteGraphBuilder {
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 {RouteGraphBuilder} this (for chaining)
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 RouteEdge(sources, capUrn, target, isLoop));
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 RouteEdges.
5104
+ * This converts the path into a series of MachineEdges.
4963
5105
  *
4964
5106
  * @param {CapGraphEdge[]} capGraphEdges - Array of CapGraphEdge from pathfinding
4965
- * @returns {RouteGraphBuilder} this (for chaining)
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 RouteEdge([source], edge.cap.urn, target, false));
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 RouteGraph from the accumulated edges.
4978
- * @returns {RouteGraph}
5119
+ * Build the Machine from the accumulated edges.
5120
+ * @returns {Machine}
4979
5121
  */
4980
5122
  build() {
4981
- return new RouteGraph([...this._edges]);
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
- RouteNotationError,
5105
- RouteNotationErrorCodes,
5106
- RouteEdge,
5107
- RouteGraph,
5108
- RouteGraphBuilder,
5109
- parseRouteNotation,
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
  };