capdag 0.93.23689 → 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
@@ -4272,10 +4272,16 @@ class PluginRepoServer {
4272
4272
  * Mirrors Rust MachineSyntaxError exactly.
4273
4273
  */
4274
4274
  class MachineSyntaxError extends Error {
4275
- constructor(code, message) {
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
4282
  this.name = 'MachineSyntaxError';
4278
4283
  this.code = code;
4284
+ this.location = location || null;
4279
4285
  }
4280
4286
  }
4281
4287
 
@@ -4714,6 +4720,78 @@ class Machine {
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
4797
  * Mirrors Rust Display for Machine.
@@ -4743,14 +4821,15 @@ const routeParser = require('./machine-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
4829
  throw new MachineSyntaxError(
4752
4830
  MachineSyntaxErrorCodes.INVALID_WIRING,
4753
- `invalid wiring at statement ${position}: node '${node}' has conflicting media types: existing '${existing}', new '${mediaUrn}'`
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,20 +4838,15 @@ function assignOrCheckNode(node, mediaUrn, nodeMedia, position) {
4759
4838
  }
4760
4839
 
4761
4840
  /**
4762
- * Parse machine notation into a Machine.
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_machine 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 {Machine}
4845
+ * @returns {{ machine: Machine, statements: Object[], aliasMap: Map, nodeMedia: Map }}
4773
4846
  * @throws {MachineSyntaxError}
4847
+ * @private
4774
4848
  */
4775
- function parseMachine(input) {
4849
+ function _parseMachineInternal(input) {
4776
4850
  const trimmed = input.trim();
4777
4851
  if (trimmed.length === 0) {
4778
4852
  throw new MachineSyntaxError(
@@ -4786,15 +4860,18 @@ function parseMachine(input) {
4786
4860
  try {
4787
4861
  stmts = routeParser.parse(trimmed);
4788
4862
  } catch (e) {
4863
+ // Peggy SyntaxError has .location — propagate it
4864
+ const loc = e.location || null;
4789
4865
  throw new MachineSyntaxError(
4790
4866
  MachineSyntaxErrorCodes.PARSE_ERROR,
4791
- `parse error: ${e.message}`
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];
@@ -4806,10 +4883,18 @@ function parseMachine(input) {
4806
4883
  } catch (e) {
4807
4884
  throw new MachineSyntaxError(
4808
4885
  MachineSyntaxErrorCodes.INVALID_CAP_URN,
4809
- `invalid cap URN in header '${stmt.alias}': ${e.message}`
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 parseMachine(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
4918
  throw new MachineSyntaxError(
4830
4919
  MachineSyntaxErrorCodes.DUPLICATE_ALIAS,
4831
- `duplicate alias '${header.alias}' (first defined at statement ${firstPos})`
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
4933
  // Phase 4: Resolve wirings into MachineEdges
4838
4934
  if (wirings.length === 0 && headers.length > 0) {
4839
4935
  throw new MachineSyntaxError(
4840
4936
  MachineSyntaxErrorCodes.NO_EDGES,
4841
- 'route has headers but no wirings — define at least one edge'
4937
+ 'route has headers but no wirings — define at least one edge',
4938
+ headers[headers.length - 1].location
4842
4939
  );
4843
4940
  }
4844
4941
 
@@ -4851,24 +4948,28 @@ function parseMachine(input) {
4851
4948
  if (!aliasEntry) {
4852
4949
  throw new MachineSyntaxError(
4853
4950
  MachineSyntaxErrorCodes.UNDEFINED_ALIAS,
4854
- `wiring references undefined alias '${wiring.capAlias}'`
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
4961
  throw new MachineSyntaxError(
4863
4962
  MachineSyntaxErrorCodes.NODE_ALIAS_COLLISION,
4864
- `node name '${src}' collides with cap alias '${src}'`
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
4969
  throw new MachineSyntaxError(
4870
4970
  MachineSyntaxErrorCodes.NODE_ALIAS_COLLISION,
4871
- `node name '${wiring.target}' collides with cap alias '${wiring.target}'`
4971
+ `node name '${wiring.target}' collides with cap alias '${wiring.target}'`,
4972
+ wiring.targetLocation || wiring.location
4872
4973
  );
4873
4974
  }
4874
4975
 
@@ -4879,7 +4980,8 @@ function parseMachine(input) {
4879
4980
  } catch (e) {
4880
4981
  throw new MachineSyntaxError(
4881
4982
  MachineSyntaxErrorCodes.INVALID_MEDIA_URN,
4882
- `invalid media URN in cap '${wiring.capAlias}': in= spec: ${e.message}`
4983
+ `invalid media URN in cap '${wiring.capAlias}': in= spec: ${e.message}`,
4984
+ aliasEntry.capUrnLocation || wiring.location
4883
4985
  );
4884
4986
  }
4885
4987
 
@@ -4889,7 +4991,8 @@ function parseMachine(input) {
4889
4991
  } catch (e) {
4890
4992
  throw new MachineSyntaxError(
4891
4993
  MachineSyntaxErrorCodes.INVALID_MEDIA_URN,
4892
- `invalid media URN in cap '${wiring.capAlias}': out= spec: ${e.message}`
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 parseMachine(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,12 +5019,50 @@ function parseMachine(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
5025
  edges.push(new MachineEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
4921
5026
  }
4922
5027
 
4923
- return new Machine(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
  // ============================================================================
@@ -4982,6 +5124,179 @@ class MachineBuilder {
4982
5124
  }
4983
5125
  }
4984
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();
5297
+ }
5298
+ }
5299
+
4985
5300
  // Export for CommonJS
4986
5301
  module.exports = {
4987
5302
  CapUrn,
@@ -5107,4 +5422,9 @@ module.exports = {
5107
5422
  Machine,
5108
5423
  MachineBuilder,
5109
5424
  parseMachine,
5425
+ parseMachineWithAST,
5426
+ // Cap & Media Registry
5427
+ CapRegistryEntry,
5428
+ MediaRegistryEntry,
5429
+ CapRegistryClient,
5110
5430
  };
package/capdag.test.js CHANGED
@@ -14,7 +14,8 @@ const {
14
14
  validateNoMediaSpecRedefinitionSync,
15
15
  CapArgumentValue,
16
16
  llmConversationUrn, modelAvailabilityUrn, modelPathUrn,
17
- MachineSyntaxError, MachineSyntaxErrorCodes, MachineEdge, Machine, MachineBuilder, parseMachine,
17
+ MachineSyntaxError, MachineSyntaxErrorCodes, MachineEdge, Machine, MachineBuilder, parseMachine, parseMachineWithAST,
18
+ CapRegistryEntry, MediaRegistryEntry, CapRegistryClient,
18
19
  MEDIA_STRING, MEDIA_INTEGER, MEDIA_NUMBER, MEDIA_BOOLEAN,
19
20
  MEDIA_OBJECT, MEDIA_STRING_ARRAY, MEDIA_INTEGER_ARRAY,
20
21
  MEDIA_NUMBER_ARRAY, MEDIA_BOOLEAN_ARRAY, MEDIA_OBJECT_ARRAY,
@@ -3300,6 +3301,245 @@ function testMachine_mediaUrnIsComparable() {
3300
3301
  assert(!general.isComparable(unrelated), 'Unrelated should not be comparable');
3301
3302
  }
3302
3303
 
3304
+ // ============================================================================
3305
+ // Phase 0A: Position tracking tests
3306
+ // ============================================================================
3307
+
3308
+ function testMachine_parseMachineWithAST_headerLocation() {
3309
+ const input = '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"][doc -> extract -> text]';
3310
+ const result = parseMachineWithAST(input);
3311
+ assert(result.statements.length === 2, 'Should have 2 statements');
3312
+ const stmt = result.statements[0];
3313
+ assertEqual(stmt.type, 'header', 'First statement should be a header');
3314
+ assert(stmt.location !== undefined, 'Header should have location');
3315
+ assert(stmt.location.start !== undefined, 'Location should have start');
3316
+ assert(stmt.location.end !== undefined, 'Location should have end');
3317
+ assert(stmt.location.start.line !== undefined, 'Start should have line');
3318
+ assert(stmt.location.start.column !== undefined, 'Start should have column');
3319
+ assert(stmt.aliasLocation !== undefined, 'Header should have aliasLocation');
3320
+ assert(stmt.capUrnLocation !== undefined, 'Header should have capUrnLocation');
3321
+ assertEqual(stmt.alias, 'extract', 'Alias should be extract');
3322
+ }
3323
+
3324
+ function testMachine_parseMachineWithAST_wiringLocation() {
3325
+ const input = '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]\n[doc -> extract -> text]';
3326
+ const result = parseMachineWithAST(input);
3327
+ assert(result.statements.length === 2, 'Should have 2 statements');
3328
+ const wiring = result.statements[1];
3329
+ assertEqual(wiring.type, 'wiring', 'Second statement should be a wiring');
3330
+ assert(wiring.location !== undefined, 'Wiring should have location');
3331
+ assert(wiring.sourceLocations !== undefined, 'Wiring should have sourceLocations');
3332
+ assert(wiring.sourceLocations.length === 1, 'Should have 1 source location');
3333
+ assert(wiring.capAliasLocation !== undefined, 'Wiring should have capAliasLocation');
3334
+ assert(wiring.targetLocation !== undefined, 'Wiring should have targetLocation');
3335
+ assertEqual(wiring.target, 'text', 'Target should be text');
3336
+ }
3337
+
3338
+ function testMachine_parseMachineWithAST_multilinePositions() {
3339
+ const input = '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]\n[doc -> extract -> text]';
3340
+ const result = parseMachineWithAST(input);
3341
+ const headerLoc = result.statements[0].location;
3342
+ const wiringLoc = result.statements[1].location;
3343
+ assertEqual(headerLoc.start.line, 1, 'Header should be on line 1');
3344
+ assertEqual(wiringLoc.start.line, 2, 'Wiring should be on line 2');
3345
+ }
3346
+
3347
+ function testMachine_parseMachineWithAST_fanInSourceLocations() {
3348
+ const input = [
3349
+ '[describe cap:in="media:image;png";op=describe_image;out="media:image-description;textable"]',
3350
+ '[(thumbnail, model_spec) -> describe -> description]'
3351
+ ].join('\n');
3352
+ const result = parseMachineWithAST(input);
3353
+ const wiring = result.statements[1];
3354
+ assertEqual(wiring.sources.length, 2, 'Fan-in should have 2 sources');
3355
+ assert(wiring.sourceLocations.length === 2, 'Should have 2 source locations');
3356
+ }
3357
+
3358
+ function testMachine_parseMachineWithAST_aliasMap() {
3359
+ const input = [
3360
+ '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]',
3361
+ '[embed cap:in="media:txt;textable";op=embed;out="media:embedding-vector;record;textable"]',
3362
+ '[doc -> extract -> text]',
3363
+ '[text -> embed -> vectors]',
3364
+ ].join('\n');
3365
+ const result = parseMachineWithAST(input);
3366
+ assert(result.aliasMap.has('extract'), 'aliasMap should have extract');
3367
+ assert(result.aliasMap.has('embed'), 'aliasMap should have embed');
3368
+ assertEqual(result.aliasMap.size, 2, 'aliasMap should have 2 entries');
3369
+ const extractEntry = result.aliasMap.get('extract');
3370
+ assert(extractEntry.capUrn !== undefined, 'Alias entry should have capUrn');
3371
+ assert(extractEntry.location !== undefined, 'Alias entry should have location');
3372
+ assert(extractEntry.aliasLocation !== undefined, 'Alias entry should have aliasLocation');
3373
+ assert(extractEntry.capUrnLocation !== undefined, 'Alias entry should have capUrnLocation');
3374
+ }
3375
+
3376
+ function testMachine_parseMachineWithAST_nodeMedia() {
3377
+ const input = [
3378
+ '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]',
3379
+ '[doc -> extract -> text]',
3380
+ ].join('\n');
3381
+ const result = parseMachineWithAST(input);
3382
+ assert(result.nodeMedia.has('doc'), 'nodeMedia should have doc');
3383
+ assert(result.nodeMedia.has('text'), 'nodeMedia should have text');
3384
+ assertEqual(result.nodeMedia.get('doc').toString(), 'media:pdf', 'doc should be media:pdf');
3385
+ assertEqual(result.nodeMedia.get('text').toString(), 'media:textable;txt', 'text should be media:textable;txt');
3386
+ }
3387
+
3388
+ function testMachine_errorLocation_parseError() {
3389
+ try {
3390
+ parseMachine('[this is not valid');
3391
+ throw new Error('Expected MachineSyntaxError');
3392
+ } catch (e) {
3393
+ assertEqual(e.code, MachineSyntaxErrorCodes.PARSE_ERROR, 'Should be PARSE_ERROR');
3394
+ assert(e.location !== null, 'Parse error should have location');
3395
+ }
3396
+ }
3397
+
3398
+ function testMachine_errorLocation_duplicateAlias() {
3399
+ try {
3400
+ parseMachine(
3401
+ '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]' +
3402
+ '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]' +
3403
+ '[doc -> extract -> text]'
3404
+ );
3405
+ throw new Error('Expected MachineSyntaxError');
3406
+ } catch (e) {
3407
+ assertEqual(e.code, MachineSyntaxErrorCodes.DUPLICATE_ALIAS, 'Should be DUPLICATE_ALIAS');
3408
+ assert(e.location !== null, 'Duplicate alias error should have location');
3409
+ }
3410
+ }
3411
+
3412
+ function testMachine_errorLocation_undefinedAlias() {
3413
+ try {
3414
+ parseMachine('[doc -> nonexistent -> text]');
3415
+ throw new Error('Expected MachineSyntaxError');
3416
+ } catch (e) {
3417
+ assertEqual(e.code, MachineSyntaxErrorCodes.UNDEFINED_ALIAS, 'Should be UNDEFINED_ALIAS');
3418
+ assert(e.location !== null, 'Undefined alias error should have location');
3419
+ }
3420
+ }
3421
+
3422
+ // ============================================================================
3423
+ // Phase 0C: Machine.toMermaid() tests
3424
+ // ============================================================================
3425
+
3426
+ function testMachine_toMermaid_linearChain() {
3427
+ const machine = Machine.fromString(
3428
+ '[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]' +
3429
+ '[doc -> extract -> text]'
3430
+ );
3431
+ const mermaid = machine.toMermaid();
3432
+ assert(mermaid.startsWith('flowchart LR'), 'Should start with flowchart LR');
3433
+ assert(mermaid.includes('extract'), 'Should include extract label');
3434
+ assert(mermaid.includes('media:pdf'), 'Should include media:pdf node');
3435
+ assert(mermaid.includes('media:textable;txt'), 'Should include media:textable;txt node');
3436
+ assert(mermaid.includes('-->'), 'Should include arrow');
3437
+ // Root source and leaf target should both be stadium shape
3438
+ assert(mermaid.includes('(['), 'Should have stadium shape nodes');
3439
+ }
3440
+
3441
+ function testMachine_toMermaid_loopEdge() {
3442
+ const machine = Machine.fromString(
3443
+ '[p2t cap:in="media:disbound-page;textable";op=page_to_text;out="media:txt;textable"]' +
3444
+ '[pages -> LOOP p2t -> texts]'
3445
+ );
3446
+ const mermaid = machine.toMermaid();
3447
+ assert(mermaid.includes('LOOP'), 'Should include LOOP label');
3448
+ assert(mermaid.includes('-.'), 'Should use dotted line for LOOP');
3449
+ assert(mermaid.includes('.->'), 'Should use dotted arrow for LOOP');
3450
+ }
3451
+
3452
+ function testMachine_toMermaid_emptyGraph() {
3453
+ const machine = Machine.empty();
3454
+ const mermaid = machine.toMermaid();
3455
+ assert(mermaid.includes('empty graph'), 'Should indicate empty graph');
3456
+ }
3457
+
3458
+ function testMachine_toMermaid_fanIn() {
3459
+ const machine = Machine.fromString(
3460
+ '[describe cap:in="media:image;png";op=describe_image;out="media:image-description;textable"]' +
3461
+ '[(thumbnail, model_spec) -> describe -> description]'
3462
+ );
3463
+ const mermaid = machine.toMermaid();
3464
+ // Fan-in should produce two arrows pointing to the same target
3465
+ const arrowCount = (mermaid.match(/-->/g) || []).length;
3466
+ assertEqual(arrowCount, 2, 'Fan-in should produce 2 arrows');
3467
+ }
3468
+
3469
+ function testMachine_toMermaid_fanOut() {
3470
+ const input = [
3471
+ '[meta cap:in="media:pdf";op=extract_metadata;out="media:file-metadata;record;textable"]',
3472
+ '[thumb cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"]',
3473
+ '[doc -> meta -> metadata]',
3474
+ '[doc -> thumb -> thumbnail]'
3475
+ ].join('');
3476
+ const machine = Machine.fromString(input);
3477
+ const mermaid = machine.toMermaid();
3478
+ // Should have 2 edges
3479
+ const arrowCount = (mermaid.match(/-->/g) || []).length;
3480
+ assertEqual(arrowCount, 2, 'Fan-out should produce 2 arrows');
3481
+ // The root source (media:pdf) should appear once as a node definition
3482
+ assert(mermaid.includes('media:pdf'), 'Should include media:pdf');
3483
+ }
3484
+
3485
+ // ============================================================================
3486
+ // Phase 0B: CapRegistryClient tests
3487
+ // ============================================================================
3488
+
3489
+ function testMachine_capRegistryEntry_construction() {
3490
+ const entry = new CapRegistryEntry({
3491
+ urn: 'cap:in="media:pdf";op=extract;out="media:txt;textable"',
3492
+ title: 'PDF Extractor',
3493
+ command: 'extract',
3494
+ cap_description: 'Extracts text from PDF',
3495
+ args: [{ media_urn: 'media:pdf', required: true }],
3496
+ output: { media_urn: 'media:txt;textable', output_description: 'Extracted text' },
3497
+ media_specs: [],
3498
+ urn_tags: { op: 'extract' },
3499
+ in_spec: 'media:pdf',
3500
+ out_spec: 'media:txt;textable',
3501
+ in_media_title: 'PDF Document',
3502
+ out_media_title: 'Text'
3503
+ });
3504
+ assertEqual(entry.urn, 'cap:in="media:pdf";op=extract;out="media:txt;textable"', 'URN should match');
3505
+ assertEqual(entry.title, 'PDF Extractor', 'Title should match');
3506
+ assertEqual(entry.description, 'Extracts text from PDF', 'Description should match');
3507
+ assertEqual(entry.inSpec, 'media:pdf', 'inSpec should match');
3508
+ assertEqual(entry.outSpec, 'media:txt;textable', 'outSpec should match');
3509
+ assertEqual(entry.urnTags.op, 'extract', 'op tag should match');
3510
+ }
3511
+
3512
+ function testMachine_mediaRegistryEntry_construction() {
3513
+ const entry = new MediaRegistryEntry({
3514
+ urn: 'media:pdf',
3515
+ title: 'PDF Document',
3516
+ media_type: 'application/pdf',
3517
+ description: 'Portable Document Format'
3518
+ });
3519
+ assertEqual(entry.urn, 'media:pdf', 'URN should match');
3520
+ assertEqual(entry.title, 'PDF Document', 'Title should match');
3521
+ assertEqual(entry.mediaType, 'application/pdf', 'Media type should match');
3522
+ assertEqual(entry.description, 'Portable Document Format', 'Description should match');
3523
+ }
3524
+
3525
+ function testMachine_capRegistryClient_construction() {
3526
+ const client = new CapRegistryClient('https://example.com', 600);
3527
+ assert(client !== null, 'Client should be constructed');
3528
+ // Invalidate should not throw
3529
+ client.invalidate();
3530
+ }
3531
+
3532
+ function testMachine_capRegistryEntry_defaults() {
3533
+ // Verify that missing fields default gracefully
3534
+ const entry = new CapRegistryEntry({ urn: 'cap:in=media:;op=test;out=media:' });
3535
+ assertEqual(entry.urn, 'cap:in=media:;op=test;out=media:', 'URN should match');
3536
+ assertEqual(entry.title, '', 'Title should default to empty');
3537
+ assertEqual(entry.description, '', 'Description should default to empty');
3538
+ assertEqual(entry.command, '', 'Command should default to empty');
3539
+ assert(Array.isArray(entry.args), 'Args should default to array');
3540
+ assertEqual(entry.args.length, 0, 'Args should be empty');
3541
+ }
3542
+
3303
3543
  // Helper for route error tests
3304
3544
  function assertThrowsWithCode(fn, expectedCode) {
3305
3545
  try {
@@ -3621,6 +3861,33 @@ async function runTests() {
3621
3861
  runTest('ROUTE: media_urn_is_equivalent', testMachine_mediaUrnIsEquivalent);
3622
3862
  runTest('ROUTE: media_urn_is_comparable', testMachine_mediaUrnIsComparable);
3623
3863
 
3864
+ // Phase 0A: Position tracking
3865
+ console.log('\n--- route/position_tracking ---');
3866
+ runTest('ROUTE: parseMachineWithAST_headerLocation', testMachine_parseMachineWithAST_headerLocation);
3867
+ runTest('ROUTE: parseMachineWithAST_wiringLocation', testMachine_parseMachineWithAST_wiringLocation);
3868
+ runTest('ROUTE: parseMachineWithAST_multilinePositions', testMachine_parseMachineWithAST_multilinePositions);
3869
+ runTest('ROUTE: parseMachineWithAST_fanInSourceLocations', testMachine_parseMachineWithAST_fanInSourceLocations);
3870
+ runTest('ROUTE: parseMachineWithAST_aliasMap', testMachine_parseMachineWithAST_aliasMap);
3871
+ runTest('ROUTE: parseMachineWithAST_nodeMedia', testMachine_parseMachineWithAST_nodeMedia);
3872
+ runTest('ROUTE: errorLocation_parseError', testMachine_errorLocation_parseError);
3873
+ runTest('ROUTE: errorLocation_duplicateAlias', testMachine_errorLocation_duplicateAlias);
3874
+ runTest('ROUTE: errorLocation_undefinedAlias', testMachine_errorLocation_undefinedAlias);
3875
+
3876
+ // Phase 0C: Machine.toMermaid()
3877
+ console.log('\n--- route/mermaid ---');
3878
+ runTest('ROUTE: toMermaid_linearChain', testMachine_toMermaid_linearChain);
3879
+ runTest('ROUTE: toMermaid_loopEdge', testMachine_toMermaid_loopEdge);
3880
+ runTest('ROUTE: toMermaid_emptyGraph', testMachine_toMermaid_emptyGraph);
3881
+ runTest('ROUTE: toMermaid_fanIn', testMachine_toMermaid_fanIn);
3882
+ runTest('ROUTE: toMermaid_fanOut', testMachine_toMermaid_fanOut);
3883
+
3884
+ // Phase 0B: CapRegistryClient
3885
+ console.log('\n--- registry/client ---');
3886
+ runTest('REGISTRY: capRegistryEntry_construction', testMachine_capRegistryEntry_construction);
3887
+ runTest('REGISTRY: mediaRegistryEntry_construction', testMachine_mediaRegistryEntry_construction);
3888
+ runTest('REGISTRY: capRegistryClient_construction', testMachine_capRegistryClient_construction);
3889
+ runTest('REGISTRY: capRegistryEntry_defaults', testMachine_capRegistryEntry_defaults);
3890
+
3624
3891
  // Summary
3625
3892
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
3626
3893
  if (failCount > 0) {
package/machine-parser.js CHANGED
@@ -203,18 +203,20 @@ function peg$parse(input, options) {
203
203
  function peg$f0(stmts) { return stmts; }
204
204
  function peg$f1(inner) { return inner; }
205
205
  function peg$f2(a, c) {
206
- return { type: 'header', alias: a, capUrn: c };
206
+ return { type: 'header', alias: a.value, capUrn: c.value, location: location(), aliasLocation: a.location, capUrnLocation: c.location };
207
207
  }
208
208
  function peg$f3(s, lc, t) {
209
- return { type: 'wiring', sources: s, capAlias: lc.alias, isLoop: lc.isLoop, target: t };
209
+ return { type: 'wiring', sources: s.values, capAlias: lc.alias, isLoop: lc.isLoop, target: t.value, location: location(), sourceLocations: s.locations, capAliasLocation: lc.location, targetLocation: t.location };
210
210
  }
211
- function peg$f4(a) { return [a]; }
211
+ function peg$f4(a) { return { values: [a.value], locations: [a.location] }; }
212
212
  function peg$f5(first, a) { return a; }
213
213
  function peg$f6(first, rest) {
214
- return [first, ...rest];
214
+ return { values: [first.value, ...rest.map(r => r.value)], locations: [first.location, ...rest.map(r => r.location)] };
215
215
  }
216
- function peg$f7(a) { return { alias: a, isLoop: true }; }
217
- function peg$f8(a) { return { alias: a, isLoop: false }; }
216
+ function peg$f7(a) { return { alias: a.value, isLoop: true, location: a.location }; }
217
+ function peg$f8(a) { return { alias: a.value, isLoop: false, location: a.location }; }
218
+ function peg$f9(a) { return { value: a, location: location() }; }
219
+ function peg$f10(c) { return { value: c, location: location() }; }
218
220
  let peg$currPos = options.peg$currPos | 0;
219
221
  let peg$savedPos = peg$currPos;
220
222
  const peg$posDetailsCache = [{ line: 1, column: 1 }];
@@ -461,11 +463,11 @@ function peg$parse(input, options) {
461
463
  let s0, s1, s2, s3;
462
464
 
463
465
  s0 = peg$currPos;
464
- s1 = peg$parsealias();
466
+ s1 = peg$parsealias_loc();
465
467
  if (s1 !== peg$FAILED) {
466
468
  s2 = peg$parse__();
467
469
  if (s2 !== peg$FAILED) {
468
- s3 = peg$parsecap_urn();
470
+ s3 = peg$parsecap_urn_loc();
469
471
  if (s3 !== peg$FAILED) {
470
472
  peg$savedPos = s0;
471
473
  s0 = peg$f2(s1, s3);
@@ -489,19 +491,19 @@ function peg$parse(input, options) {
489
491
  let s0, s1, s2, s3, s4, s5, s6, s7, s8, s9;
490
492
 
491
493
  s0 = peg$currPos;
492
- s1 = peg$parsesource();
494
+ s1 = peg$parsesource_loc();
493
495
  if (s1 !== peg$FAILED) {
494
496
  s2 = peg$parse_();
495
497
  s3 = peg$parsearrow();
496
498
  if (s3 !== peg$FAILED) {
497
499
  s4 = peg$parse_();
498
- s5 = peg$parseloop_cap();
500
+ s5 = peg$parseloop_cap_loc();
499
501
  if (s5 !== peg$FAILED) {
500
502
  s6 = peg$parse_();
501
503
  s7 = peg$parsearrow();
502
504
  if (s7 !== peg$FAILED) {
503
505
  s8 = peg$parse_();
504
- s9 = peg$parsealias();
506
+ s9 = peg$parsealias_loc();
505
507
  if (s9 !== peg$FAILED) {
506
508
  peg$savedPos = s0;
507
509
  s0 = peg$f3(s1, s5, s9);
@@ -529,22 +531,22 @@ function peg$parse(input, options) {
529
531
  return s0;
530
532
  }
531
533
 
532
- function peg$parsesource() {
534
+ function peg$parsesource_loc() {
533
535
  let s0;
534
536
 
535
- s0 = peg$parsegroup();
537
+ s0 = peg$parsegroup_loc();
536
538
  if (s0 === peg$FAILED) {
537
- s0 = peg$parsesingle_alias();
539
+ s0 = peg$parsesingle_alias_loc();
538
540
  }
539
541
 
540
542
  return s0;
541
543
  }
542
544
 
543
- function peg$parsesingle_alias() {
545
+ function peg$parsesingle_alias_loc() {
544
546
  let s0, s1;
545
547
 
546
548
  s0 = peg$currPos;
547
- s1 = peg$parsealias();
549
+ s1 = peg$parsealias_loc();
548
550
  if (s1 !== peg$FAILED) {
549
551
  peg$savedPos = s0;
550
552
  s1 = peg$f4(s1);
@@ -554,7 +556,7 @@ function peg$parse(input, options) {
554
556
  return s0;
555
557
  }
556
558
 
557
- function peg$parsegroup() {
559
+ function peg$parsegroup_loc() {
558
560
  let s0, s1, s2, s3, s4, s5, s6, s7, s8;
559
561
 
560
562
  s0 = peg$currPos;
@@ -567,7 +569,7 @@ function peg$parse(input, options) {
567
569
  }
568
570
  if (s1 !== peg$FAILED) {
569
571
  s2 = peg$parse_();
570
- s3 = peg$parsealias();
572
+ s3 = peg$parsealias_loc();
571
573
  if (s3 !== peg$FAILED) {
572
574
  s4 = [];
573
575
  s5 = peg$currPos;
@@ -580,7 +582,7 @@ function peg$parse(input, options) {
580
582
  }
581
583
  if (s6 !== peg$FAILED) {
582
584
  s7 = peg$parse_();
583
- s8 = peg$parsealias();
585
+ s8 = peg$parsealias_loc();
584
586
  if (s8 !== peg$FAILED) {
585
587
  peg$savedPos = s5;
586
588
  s5 = peg$f5(s3, s8);
@@ -605,7 +607,7 @@ function peg$parse(input, options) {
605
607
  }
606
608
  if (s6 !== peg$FAILED) {
607
609
  s7 = peg$parse_();
608
- s8 = peg$parsealias();
610
+ s8 = peg$parsealias_loc();
609
611
  if (s8 !== peg$FAILED) {
610
612
  peg$savedPos = s5;
611
613
  s5 = peg$f5(s3, s8);
@@ -653,7 +655,7 @@ function peg$parse(input, options) {
653
655
  return s0;
654
656
  }
655
657
 
656
- function peg$parseloop_cap() {
658
+ function peg$parseloop_cap_loc() {
657
659
  let s0, s1, s2, s3;
658
660
 
659
661
  s0 = peg$currPos;
@@ -667,7 +669,7 @@ function peg$parse(input, options) {
667
669
  if (s1 !== peg$FAILED) {
668
670
  s2 = peg$parse__();
669
671
  if (s2 !== peg$FAILED) {
670
- s3 = peg$parsealias();
672
+ s3 = peg$parsealias_loc();
671
673
  if (s3 !== peg$FAILED) {
672
674
  peg$savedPos = s0;
673
675
  s0 = peg$f7(s3);
@@ -685,7 +687,7 @@ function peg$parse(input, options) {
685
687
  }
686
688
  if (s0 === peg$FAILED) {
687
689
  s0 = peg$currPos;
688
- s1 = peg$parsealias();
690
+ s1 = peg$parsealias_loc();
689
691
  if (s1 !== peg$FAILED) {
690
692
  peg$savedPos = s0;
691
693
  s1 = peg$f8(s1);
@@ -745,6 +747,20 @@ function peg$parse(input, options) {
745
747
  return s0;
746
748
  }
747
749
 
750
+ function peg$parsealias_loc() {
751
+ let s0, s1;
752
+
753
+ s0 = peg$currPos;
754
+ s1 = peg$parsealias();
755
+ if (s1 !== peg$FAILED) {
756
+ peg$savedPos = s0;
757
+ s1 = peg$f9(s1);
758
+ }
759
+ s0 = s1;
760
+
761
+ return s0;
762
+ }
763
+
748
764
  function peg$parsealias() {
749
765
  let s0, s1, s2, s3, s4;
750
766
 
@@ -791,6 +807,20 @@ function peg$parse(input, options) {
791
807
  return s0;
792
808
  }
793
809
 
810
+ function peg$parsecap_urn_loc() {
811
+ let s0, s1;
812
+
813
+ s0 = peg$currPos;
814
+ s1 = peg$parsecap_urn();
815
+ if (s1 !== peg$FAILED) {
816
+ peg$savedPos = s0;
817
+ s1 = peg$f10(s1);
818
+ }
819
+ s0 = s1;
820
+
821
+ return s0;
822
+ }
823
+
794
824
  function peg$parsecap_urn() {
795
825
  let s0, s1, s2, s3, s4;
796
826
 
package/machine.pegjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Bracket-delimited machine notation grammar for Peggy.
2
2
  //
3
3
  // This grammar mirrors the Rust pest grammar in machine.pest exactly.
4
+ // All actions return location() for LSP position tracking.
4
5
  //
5
6
  // Examples:
6
7
  // [extract cap:in="media:pdf";op=extract;out="media:txt;textable"]
@@ -15,32 +16,38 @@ stmt = "[" _ inner:inner _ "]" _ { return inner; }
15
16
  inner = wiring / header
16
17
 
17
18
  // Header: alias followed by a cap URN starting with "cap:".
18
- header = a:alias __ c:cap_urn {
19
- return { type: 'header', alias: a, capUrn: c };
19
+ header = a:alias_loc __ c:cap_urn_loc {
20
+ return { type: 'header', alias: a.value, capUrn: c.value, location: location(), aliasLocation: a.location, capUrnLocation: c.location };
20
21
  }
21
22
 
22
23
  // Wiring: source -> loop_cap -> target
23
- wiring = s:source _ arrow _ lc:loop_cap _ arrow _ t:alias {
24
- return { type: 'wiring', sources: s, capAlias: lc.alias, isLoop: lc.isLoop, target: t };
24
+ wiring = s:source_loc _ arrow _ lc:loop_cap_loc _ arrow _ t:alias_loc {
25
+ return { type: 'wiring', sources: s.values, capAlias: lc.alias, isLoop: lc.isLoop, target: t.value, location: location(), sourceLocations: s.locations, capAliasLocation: lc.location, targetLocation: t.location };
25
26
  }
26
27
 
27
- source = group / single_alias
28
+ source_loc = group_loc / single_alias_loc
28
29
 
29
- single_alias = a:alias { return [a]; }
30
+ single_alias_loc = a:alias_loc { return { values: [a.value], locations: [a.location] }; }
30
31
 
31
- group = "(" _ first:alias rest:("," _ a:alias { return a; })+ _ ")" {
32
- return [first, ...rest];
32
+ group_loc = "(" _ first:alias_loc rest:("," _ a:alias_loc { return a; })+ _ ")" {
33
+ return { values: [first.value, ...rest.map(r => r.value)], locations: [first.location, ...rest.map(r => r.location)] };
33
34
  }
34
35
 
35
- loop_cap = "LOOP" __ a:alias { return { alias: a, isLoop: true }; }
36
- / a:alias { return { alias: a, isLoop: false }; }
36
+ loop_cap_loc = "LOOP" __ a:alias_loc { return { alias: a.value, isLoop: true, location: a.location }; }
37
+ / a:alias_loc { return { alias: a.value, isLoop: false, location: a.location }; }
37
38
 
38
39
  arrow = "-"+ ">"
39
40
 
41
+ // Alias with location tracking
42
+ alias_loc = a:alias { return { value: a, location: location() }; }
43
+
40
44
  // Alias: starts with alpha or underscore, continues with alphanumeric, underscore, or hyphen.
41
45
  // This is atomic — no whitespace skipping inside.
42
46
  alias = $( [a-zA-Z_] [a-zA-Z0-9_-]* )
43
47
 
48
+ // Cap URN with location tracking
49
+ cap_urn_loc = c:cap_urn { return { value: c, location: location() }; }
50
+
44
51
  // Cap URN: starts with "cap:", reads until the statement-closing "]",
45
52
  // except quoted strings can contain "]".
46
53
  cap_urn = $( "cap:" cap_urn_body* )
package/package.json CHANGED
@@ -37,5 +37,5 @@
37
37
  "pretest": "npm run build:parser",
38
38
  "test": "node capdag.test.js"
39
39
  },
40
- "version": "0.93.23689"
40
+ "version": "0.94.24331"
41
41
  }