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 +351 -31
- package/capdag.test.js +268 -1
- package/machine-parser.js +53 -23
- package/machine.pegjs +17 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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 = [];
|
|
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];
|
|
@@ -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({
|
|
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();
|
|
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, {
|
|
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 (
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
534
|
+
function peg$parsesource_loc() {
|
|
533
535
|
let s0;
|
|
534
536
|
|
|
535
|
-
s0 = peg$
|
|
537
|
+
s0 = peg$parsegroup_loc();
|
|
536
538
|
if (s0 === peg$FAILED) {
|
|
537
|
-
s0 = peg$
|
|
539
|
+
s0 = peg$parsesingle_alias_loc();
|
|
538
540
|
}
|
|
539
541
|
|
|
540
542
|
return s0;
|
|
541
543
|
}
|
|
542
544
|
|
|
543
|
-
function peg$
|
|
545
|
+
function peg$parsesingle_alias_loc() {
|
|
544
546
|
let s0, s1;
|
|
545
547
|
|
|
546
548
|
s0 = peg$currPos;
|
|
547
|
-
s1 = peg$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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:
|
|
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:
|
|
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
|
-
|
|
28
|
+
source_loc = group_loc / single_alias_loc
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
single_alias_loc = a:alias_loc { return { values: [a.value], locations: [a.location] }; }
|
|
30
31
|
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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