capdag 0.98.25321 → 0.102.232
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 +114 -7
- package/capdag.test.js +209 -4
- package/machine.pegjs +19 -7
- package/package.json +1 -1
package/capdag.js
CHANGED
|
@@ -1222,13 +1222,14 @@ class MediaSpec {
|
|
|
1222
1222
|
* @param {string|null} profile - Optional profile URL
|
|
1223
1223
|
* @param {Object|null} schema - Optional JSON Schema for local validation
|
|
1224
1224
|
* @param {string|null} title - Optional display-friendly title
|
|
1225
|
-
* @param {string|null} description - Optional description
|
|
1225
|
+
* @param {string|null} description - Optional short plain-text description
|
|
1226
1226
|
* @param {string|null} mediaUrn - Source media URN for tag-based checks
|
|
1227
1227
|
* @param {Object|null} validation - Optional validation rules (min, max, min_length, max_length, pattern, allowed_values)
|
|
1228
1228
|
* @param {Object|null} metadata - Optional metadata (arbitrary key-value pairs for display/categorization)
|
|
1229
1229
|
* @param {string[]} extensions - File extensions for storing this media type (e.g., ['pdf'], ['jpg', 'jpeg'])
|
|
1230
|
+
* @param {string|null} documentation - Optional long-form markdown documentation. Rendered in media info panels, the cap navigator, capdag-dot-com, and anywhere else a rich-text explanation of the media spec is useful.
|
|
1230
1231
|
*/
|
|
1231
|
-
constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = []) {
|
|
1232
|
+
constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = [], documentation = null) {
|
|
1232
1233
|
this.contentType = contentType;
|
|
1233
1234
|
this.profile = profile;
|
|
1234
1235
|
this.schema = schema;
|
|
@@ -1238,6 +1239,7 @@ class MediaSpec {
|
|
|
1238
1239
|
this.validation = validation;
|
|
1239
1240
|
this.metadata = metadata;
|
|
1240
1241
|
this.extensions = extensions;
|
|
1242
|
+
this.documentation = documentation;
|
|
1241
1243
|
}
|
|
1242
1244
|
|
|
1243
1245
|
/**
|
|
@@ -1387,12 +1389,19 @@ function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
|
|
|
1387
1389
|
const def = mediaSpecs.find(spec => spec.urn === mediaUrn);
|
|
1388
1390
|
|
|
1389
1391
|
if (def) {
|
|
1390
|
-
// Object form: { urn, media_type, title, profile_uri?, schema?, description?, validation?, metadata?, extensions? }
|
|
1392
|
+
// Object form: { urn, media_type, title, profile_uri?, schema?, description?, documentation?, validation?, metadata?, extensions? }
|
|
1391
1393
|
const mediaType = def.media_type || def.mediaType;
|
|
1392
1394
|
const profileUri = def.profile_uri || def.profileUri || null;
|
|
1393
1395
|
const schema = def.schema || null;
|
|
1394
1396
|
const title = def.title || null;
|
|
1395
1397
|
const description = def.description || null;
|
|
1398
|
+
// Long-form markdown body for rich info panels. Strict
|
|
1399
|
+
// snake_case (`documentation`) to match the JSON schema; no
|
|
1400
|
+
// camelCase fallback because all generator pipelines write the
|
|
1401
|
+
// canonical form.
|
|
1402
|
+
const documentation = typeof def.documentation === 'string' && def.documentation.length > 0
|
|
1403
|
+
? def.documentation
|
|
1404
|
+
: null;
|
|
1396
1405
|
const validation = def.validation || null;
|
|
1397
1406
|
const metadata = def.metadata || null;
|
|
1398
1407
|
const extensions = Array.isArray(def.extensions) ? def.extensions : [];
|
|
@@ -1404,7 +1413,7 @@ function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
|
|
|
1404
1413
|
);
|
|
1405
1414
|
}
|
|
1406
1415
|
|
|
1407
|
-
return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions);
|
|
1416
|
+
return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions, documentation);
|
|
1408
1417
|
}
|
|
1409
1418
|
}
|
|
1410
1419
|
|
|
@@ -1781,6 +1790,7 @@ class CapArg {
|
|
|
1781
1790
|
constructor(mediaUrn, required, sources, options = {}) {
|
|
1782
1791
|
this.media_urn = mediaUrn;
|
|
1783
1792
|
this.required = required;
|
|
1793
|
+
this.is_sequence = options.is_sequence || false;
|
|
1784
1794
|
this.sources = sources; // Array of ArgSource
|
|
1785
1795
|
this.arg_description = options.arg_description || null;
|
|
1786
1796
|
this.default_value = options.default_value !== undefined ? options.default_value : null;
|
|
@@ -1799,6 +1809,7 @@ class CapArg {
|
|
|
1799
1809
|
json.required,
|
|
1800
1810
|
sources,
|
|
1801
1811
|
{
|
|
1812
|
+
is_sequence: json.is_sequence,
|
|
1802
1813
|
arg_description: json.arg_description,
|
|
1803
1814
|
default_value: json.default_value,
|
|
1804
1815
|
metadata: json.metadata
|
|
@@ -1816,6 +1827,7 @@ class CapArg {
|
|
|
1816
1827
|
required: this.required,
|
|
1817
1828
|
sources: this.sources.map(s => s.toJSON())
|
|
1818
1829
|
};
|
|
1830
|
+
if (this.is_sequence) result.is_sequence = true;
|
|
1819
1831
|
if (this.arg_description) result.arg_description = this.arg_description;
|
|
1820
1832
|
if (this.default_value !== null && this.default_value !== undefined) {
|
|
1821
1833
|
result.default_value = this.default_value;
|
|
@@ -1885,11 +1897,12 @@ class Cap {
|
|
|
1885
1897
|
* @param {CapUrn} urn - The capability URN
|
|
1886
1898
|
* @param {string} title - The human-readable title (required)
|
|
1887
1899
|
* @param {string} command - The command string
|
|
1888
|
-
* @param {string|null} capDescription - Optional description
|
|
1900
|
+
* @param {string|null} capDescription - Optional short plain-text description
|
|
1889
1901
|
* @param {Object} metadata - Optional metadata object
|
|
1890
1902
|
* @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
|
|
1903
|
+
* @param {string|null} documentation - Optional long-form markdown documentation. Rendered in capability info panels, the cap navigator, capdag-dot-com, and anywhere else a rich-text explanation of the cap is useful.
|
|
1891
1904
|
*/
|
|
1892
|
-
constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null) {
|
|
1905
|
+
constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
|
|
1893
1906
|
if (!(urn instanceof CapUrn)) {
|
|
1894
1907
|
throw new Error('URN must be a CapUrn instance');
|
|
1895
1908
|
}
|
|
@@ -1904,6 +1917,7 @@ class Cap {
|
|
|
1904
1917
|
this.title = title;
|
|
1905
1918
|
this.command = command;
|
|
1906
1919
|
this.cap_description = capDescription;
|
|
1920
|
+
this.documentation = documentation;
|
|
1907
1921
|
this.metadata = metadata || {};
|
|
1908
1922
|
this.mediaSpecs = []; // Media spec definitions array
|
|
1909
1923
|
this.args = []; // Array of CapArg - unified argument format
|
|
@@ -1912,6 +1926,31 @@ class Cap {
|
|
|
1912
1926
|
this.registered_by = null; // Registration attribution
|
|
1913
1927
|
}
|
|
1914
1928
|
|
|
1929
|
+
/**
|
|
1930
|
+
* Get the long-form markdown documentation, if any.
|
|
1931
|
+
* @returns {string|null}
|
|
1932
|
+
*/
|
|
1933
|
+
getDocumentation() {
|
|
1934
|
+
return this.documentation;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Set the long-form markdown documentation.
|
|
1939
|
+
* @param {string|null} documentation
|
|
1940
|
+
*/
|
|
1941
|
+
setDocumentation(documentation) {
|
|
1942
|
+
this.documentation = (typeof documentation === 'string' && documentation.length > 0)
|
|
1943
|
+
? documentation
|
|
1944
|
+
: null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Clear the long-form markdown documentation.
|
|
1949
|
+
*/
|
|
1950
|
+
clearDocumentation() {
|
|
1951
|
+
this.documentation = null;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1915
1954
|
/**
|
|
1916
1955
|
* Get the media type expected for stdin (derived from args with stdin source)
|
|
1917
1956
|
* @returns {string|null} The media URN for stdin, or null if cap doesn't accept stdin
|
|
@@ -2095,6 +2134,7 @@ class Cap {
|
|
|
2095
2134
|
this.title === other.title &&
|
|
2096
2135
|
this.command === other.command &&
|
|
2097
2136
|
this.cap_description === other.cap_description &&
|
|
2137
|
+
this.documentation === other.documentation &&
|
|
2098
2138
|
JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
|
|
2099
2139
|
JSON.stringify(this.mediaSpecs) === JSON.stringify(other.mediaSpecs) &&
|
|
2100
2140
|
JSON.stringify(this.args.map(a => a.toJSON())) === JSON.stringify(other.args.map(a => a.toJSON())) &&
|
|
@@ -2119,6 +2159,12 @@ class Cap {
|
|
|
2119
2159
|
output: this.output
|
|
2120
2160
|
};
|
|
2121
2161
|
|
|
2162
|
+
// Long-form markdown documentation. Only emitted when set, to match
|
|
2163
|
+
// the Rust serializer which skips this field when None.
|
|
2164
|
+
if (typeof this.documentation === 'string' && this.documentation.length > 0) {
|
|
2165
|
+
result.documentation = this.documentation;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2122
2168
|
if (this.metadata_json !== null && this.metadata_json !== undefined) {
|
|
2123
2169
|
result.metadata_json = this.metadata_json;
|
|
2124
2170
|
}
|
|
@@ -2138,7 +2184,10 @@ class Cap {
|
|
|
2138
2184
|
}
|
|
2139
2185
|
const urn = CapUrn.fromString(json.urn);
|
|
2140
2186
|
|
|
2141
|
-
const
|
|
2187
|
+
const documentation = (typeof json.documentation === 'string' && json.documentation.length > 0)
|
|
2188
|
+
? json.documentation
|
|
2189
|
+
: null;
|
|
2190
|
+
const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json, documentation);
|
|
2142
2191
|
cap.mediaSpecs = json.media_specs || json.mediaSpecs || [];
|
|
2143
2192
|
// Parse args (new format)
|
|
2144
2193
|
if (json.args && Array.isArray(json.args)) {
|
|
@@ -4645,6 +4694,64 @@ class Machine {
|
|
|
4645
4694
|
return lines.join('\n');
|
|
4646
4695
|
}
|
|
4647
4696
|
|
|
4697
|
+
/**
|
|
4698
|
+
* Serialize this machine graph to machine notation in the specified format.
|
|
4699
|
+
*
|
|
4700
|
+
* The output is deterministic: same graph + same format → same string.
|
|
4701
|
+
* @param {'bracketed' | 'line-based'} format - The notation format to use.
|
|
4702
|
+
* @returns {string}
|
|
4703
|
+
*/
|
|
4704
|
+
toMachineNotationFormatted(format) {
|
|
4705
|
+
if (this._edges.length === 0) {
|
|
4706
|
+
return '';
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
const { aliases, nodeNames, edgeOrder } = this._buildSerializationMaps();
|
|
4710
|
+
const bracketed = format === 'bracketed';
|
|
4711
|
+
const open = bracketed ? '[' : '';
|
|
4712
|
+
const close = bracketed ? ']' : '';
|
|
4713
|
+
const lines = [];
|
|
4714
|
+
|
|
4715
|
+
// Emit headers in alias-sorted order
|
|
4716
|
+
const sortedAliases = Array.from(aliases.entries()).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
4717
|
+
|
|
4718
|
+
for (const [alias, { edgeIdx }] of sortedAliases) {
|
|
4719
|
+
const edge = this._edges[edgeIdx];
|
|
4720
|
+
lines.push(`${open}${alias} ${edge.capUrn}${close}`);
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
// Emit wirings in edge order
|
|
4724
|
+
for (const edgeIdx of edgeOrder) {
|
|
4725
|
+
const edge = this._edges[edgeIdx];
|
|
4726
|
+
let alias = null;
|
|
4727
|
+
for (const [a, info] of aliases) {
|
|
4728
|
+
if (info.edgeIdx === edgeIdx) {
|
|
4729
|
+
alias = a;
|
|
4730
|
+
break;
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
const sources = edge.sources.map(s => {
|
|
4735
|
+
const key = s.toString();
|
|
4736
|
+
return nodeNames.get(key);
|
|
4737
|
+
});
|
|
4738
|
+
|
|
4739
|
+
const targetKey = edge.target.toString();
|
|
4740
|
+
const targetName = nodeNames.get(targetKey);
|
|
4741
|
+
|
|
4742
|
+
const loopPrefix = edge.isLoop ? 'LOOP ' : '';
|
|
4743
|
+
|
|
4744
|
+
if (sources.length === 1) {
|
|
4745
|
+
lines.push(`${open}${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}${close}`);
|
|
4746
|
+
} else {
|
|
4747
|
+
const group = sources.join(', ');
|
|
4748
|
+
lines.push(`${open}(${group}) -> ${loopPrefix}${alias} -> ${targetName}${close}`);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
return bracketed ? lines.join('') : lines.join('\n');
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4648
4755
|
/**
|
|
4649
4756
|
* Build the alias map, node name map, and edge ordering for serialization.
|
|
4650
4757
|
*
|
package/capdag.test.js
CHANGED
|
@@ -1072,7 +1072,7 @@ function test104_resolvedIsText() {
|
|
|
1072
1072
|
function test105_metadataPropagation() {
|
|
1073
1073
|
const mediaSpecs = [
|
|
1074
1074
|
{
|
|
1075
|
-
urn: 'media:custom-setting
|
|
1075
|
+
urn: 'media:custom-setting',
|
|
1076
1076
|
media_type: 'text/plain',
|
|
1077
1077
|
title: 'Custom Setting',
|
|
1078
1078
|
profile_uri: 'https://example.com/schema',
|
|
@@ -1084,7 +1084,7 @@ function test105_metadataPropagation() {
|
|
|
1084
1084
|
}
|
|
1085
1085
|
}
|
|
1086
1086
|
];
|
|
1087
|
-
const resolved = resolveMediaUrn('media:custom-setting
|
|
1087
|
+
const resolved = resolveMediaUrn('media:custom-setting', mediaSpecs);
|
|
1088
1088
|
assert(resolved.metadata !== null, 'Should have metadata');
|
|
1089
1089
|
assertEqual(resolved.metadata.category_key, 'interface', 'Should propagate category_key');
|
|
1090
1090
|
assertEqual(resolved.metadata.ui_type, 'SETTING_UI_TYPE_CHECKBOX', 'Should propagate ui_type');
|
|
@@ -1095,14 +1095,14 @@ function test105_metadataPropagation() {
|
|
|
1095
1095
|
function test106_metadataWithValidation() {
|
|
1096
1096
|
const mediaSpecs = [
|
|
1097
1097
|
{
|
|
1098
|
-
urn: 'media:bounded-number;numeric
|
|
1098
|
+
urn: 'media:bounded-number;numeric',
|
|
1099
1099
|
media_type: 'text/plain',
|
|
1100
1100
|
title: 'Bounded Number',
|
|
1101
1101
|
validation: { min: 0, max: 100 },
|
|
1102
1102
|
metadata: { category_key: 'inference', ui_type: 'SETTING_UI_TYPE_SLIDER' }
|
|
1103
1103
|
}
|
|
1104
1104
|
];
|
|
1105
|
-
const resolved = resolveMediaUrn('media:bounded-number;numeric
|
|
1105
|
+
const resolved = resolveMediaUrn('media:bounded-number;numeric', mediaSpecs);
|
|
1106
1106
|
assert(resolved.validation !== null, 'Should have validation');
|
|
1107
1107
|
assertEqual(resolved.validation.min, 0, 'Should have min');
|
|
1108
1108
|
assertEqual(resolved.validation.max, 100, 'Should have max');
|
|
@@ -1836,6 +1836,86 @@ function testJS_capJSONSerialization() {
|
|
|
1836
1836
|
assertEqual(restored.urn.getOutSpec(), MEDIA_OBJECT, 'Should restore outSpec');
|
|
1837
1837
|
}
|
|
1838
1838
|
|
|
1839
|
+
// JS round-trip for the documentation field on Cap. Mirrors TEST920 in
|
|
1840
|
+
// capdag/src/cap/definition.rs — the body is non-trivial (newlines,
|
|
1841
|
+
// backticks, embedded quotes, Unicode) so escaping mismatches between
|
|
1842
|
+
// JSON.stringify on this side and the Rust serializer on the other side
|
|
1843
|
+
// surface as failures here.
|
|
1844
|
+
function testJS_capDocumentationRoundTrip() {
|
|
1845
|
+
const urn = CapUrn.fromString(testUrn('op=documented'));
|
|
1846
|
+
const cap = new Cap(urn, 'Documented Cap', 'documented');
|
|
1847
|
+
const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
|
|
1848
|
+
cap.setDocumentation(body);
|
|
1849
|
+
assertEqual(cap.getDocumentation(), body, 'Setter must store the body verbatim');
|
|
1850
|
+
|
|
1851
|
+
const json = cap.toJSON();
|
|
1852
|
+
assertEqual(json.documentation, body, 'toJSON must include documentation when set');
|
|
1853
|
+
|
|
1854
|
+
// Stringify and parse to simulate writing to disk and reading back.
|
|
1855
|
+
const wireJson = JSON.parse(JSON.stringify(json));
|
|
1856
|
+
const restored = Cap.fromJSON(wireJson);
|
|
1857
|
+
assertEqual(restored.getDocumentation(), body, 'fromJSON must preserve documentation body verbatim');
|
|
1858
|
+
assert(restored.equals(cap), 'Round-tripped cap must equal the original');
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// When documentation is null, toJSON must omit the field entirely. This
|
|
1862
|
+
// matches the Rust serializer's skip-when-None semantics and the ObjC
|
|
1863
|
+
// toDictionary behaviour. A regression where null is emitted as
|
|
1864
|
+
// `documentation: null` would break the symmetric round-trip with Rust
|
|
1865
|
+
// (which has no null sentinel) and pollute generated JSON.
|
|
1866
|
+
function testJS_capDocumentationOmittedWhenNull() {
|
|
1867
|
+
const urn = CapUrn.fromString(testUrn('op=undocumented'));
|
|
1868
|
+
const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
|
|
1869
|
+
assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
|
|
1870
|
+
|
|
1871
|
+
const json = cap.toJSON();
|
|
1872
|
+
assert(!('documentation' in json), 'toJSON must omit documentation key when null');
|
|
1873
|
+
|
|
1874
|
+
// fromJSON of a missing key must yield null, not undefined or empty string.
|
|
1875
|
+
const restored = Cap.fromJSON(JSON.parse(JSON.stringify(json)));
|
|
1876
|
+
assertEqual(restored.getDocumentation(), null, 'Missing documentation must round-trip as null');
|
|
1877
|
+
|
|
1878
|
+
// Empty-string body is treated as absent (matches the resolver's
|
|
1879
|
+
// non-empty-string-only rule). This catches code paths that would store
|
|
1880
|
+
// an empty string and then emit it as a literal field.
|
|
1881
|
+
cap.setDocumentation('');
|
|
1882
|
+
assertEqual(cap.getDocumentation(), null, 'Empty string must collapse to null');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Documentation propagates from a mediaSpecs definition through
|
|
1886
|
+
// resolveMediaUrn into the resolved MediaSpec. Mirrors TEST924 on the Rust
|
|
1887
|
+
// side. This is the path every UI consumer uses, so a break here makes the
|
|
1888
|
+
// new field invisible everywhere downstream.
|
|
1889
|
+
function testJS_mediaSpecDocumentationPropagatesThroughResolve() {
|
|
1890
|
+
const body = '## Markdown body\n\nWith `code` and a [link](https://example.com).';
|
|
1891
|
+
const mediaSpecs = [
|
|
1892
|
+
{
|
|
1893
|
+
urn: 'media:doc-test;textable',
|
|
1894
|
+
media_type: 'text/plain',
|
|
1895
|
+
title: 'Documented',
|
|
1896
|
+
description: 'short desc',
|
|
1897
|
+
documentation: body
|
|
1898
|
+
}
|
|
1899
|
+
];
|
|
1900
|
+
|
|
1901
|
+
const resolved = resolveMediaUrn('media:doc-test;textable', mediaSpecs);
|
|
1902
|
+
assertEqual(resolved.documentation, body, 'documentation must propagate into MediaSpec');
|
|
1903
|
+
// The short description must remain distinct from the long markdown
|
|
1904
|
+
// body — they are different fields with different semantics.
|
|
1905
|
+
assertEqual(resolved.description, 'short desc', 'description must remain distinct from documentation');
|
|
1906
|
+
|
|
1907
|
+
// Missing documentation must collapse to null, not '' or undefined.
|
|
1908
|
+
const noDoc = resolveMediaUrn('media:doc-test;textable', [
|
|
1909
|
+
{ urn: 'media:doc-test;textable', media_type: 'text/plain', title: 'No Doc' }
|
|
1910
|
+
]);
|
|
1911
|
+
assertEqual(noDoc.documentation, null, 'Missing documentation must resolve to null');
|
|
1912
|
+
|
|
1913
|
+
const emptyDoc = resolveMediaUrn('media:doc-test;textable', [
|
|
1914
|
+
{ urn: 'media:doc-test;textable', media_type: 'text/plain', title: 'Empty', documentation: '' }
|
|
1915
|
+
]);
|
|
1916
|
+
assertEqual(emptyDoc.documentation, null, 'Empty documentation string must collapse to null');
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1839
1919
|
function testJS_stdinSourceKindConstants() {
|
|
1840
1920
|
assert(StdinSourceKind.DATA !== undefined, 'DATA kind should be defined');
|
|
1841
1921
|
assert(StdinSourceKind.FILE_REFERENCE !== undefined, 'FILE_REFERENCE kind should be defined');
|
|
@@ -2793,6 +2873,117 @@ function testMachine_unterminatedBracketFails() {
|
|
|
2793
2873
|
);
|
|
2794
2874
|
}
|
|
2795
2875
|
|
|
2876
|
+
// --- Machine parser line-based mode tests ---
|
|
2877
|
+
|
|
2878
|
+
function testMachine_lineBasedSimpleChain() {
|
|
2879
|
+
const g = Machine.fromString(
|
|
2880
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2881
|
+
'doc -> extract -> text'
|
|
2882
|
+
);
|
|
2883
|
+
assertEqual(g.edgeCount(), 1);
|
|
2884
|
+
const edge = g.edges()[0];
|
|
2885
|
+
assert(edge.sources[0].isEquivalent(MediaUrn.fromString('media:pdf')),
|
|
2886
|
+
'Source should be media:pdf');
|
|
2887
|
+
assert(edge.target.isEquivalent(MediaUrn.fromString('media:txt;textable')),
|
|
2888
|
+
'Target should be media:txt;textable');
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
function testMachine_lineBasedTwoStepChain() {
|
|
2892
|
+
const g = Machine.fromString(
|
|
2893
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2894
|
+
'embed cap:in="media:txt;textable";op=embed;out="media:embedding-vector;record;textable"\n' +
|
|
2895
|
+
'doc -> extract -> text\n' +
|
|
2896
|
+
'text -> embed -> vectors'
|
|
2897
|
+
);
|
|
2898
|
+
assertEqual(g.edgeCount(), 2);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function testMachine_lineBasedLoop() {
|
|
2902
|
+
const g = Machine.fromString(
|
|
2903
|
+
'p2t cap:in="media:disbound-page;textable";op=page_to_text;out="media:txt;textable"\n' +
|
|
2904
|
+
'pages -> LOOP p2t -> texts'
|
|
2905
|
+
);
|
|
2906
|
+
assertEqual(g.edgeCount(), 1);
|
|
2907
|
+
assertEqual(g.edges()[0].isLoop, true);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
function testMachine_lineBasedFanIn() {
|
|
2911
|
+
const g = Machine.fromString(
|
|
2912
|
+
'thumb cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"\n' +
|
|
2913
|
+
'model_dl cap:in="media:model-spec;textable";op=download;out="media:model-spec;textable"\n' +
|
|
2914
|
+
'describe cap:in="media:image;png";op=describe_image;out="media:image-description;textable"\n' +
|
|
2915
|
+
'doc -> thumb -> thumbnail\n' +
|
|
2916
|
+
'spec_input -> model_dl -> model_spec\n' +
|
|
2917
|
+
'(thumbnail, model_spec) -> describe -> description'
|
|
2918
|
+
);
|
|
2919
|
+
assertEqual(g.edgeCount(), 3);
|
|
2920
|
+
assertEqual(g.edges()[2].sources.length, 2);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function testMachine_mixedBracketedAndLineBased() {
|
|
2924
|
+
const g = Machine.fromString(
|
|
2925
|
+
'[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]\n' +
|
|
2926
|
+
'doc -> extract -> text'
|
|
2927
|
+
);
|
|
2928
|
+
assertEqual(g.edgeCount(), 1);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
function testMachine_lineBasedEquivalentToBracketed() {
|
|
2932
|
+
const g1 = Machine.fromString(
|
|
2933
|
+
'[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]' +
|
|
2934
|
+
'[doc -> extract -> text]'
|
|
2935
|
+
);
|
|
2936
|
+
const g2 = Machine.fromString(
|
|
2937
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2938
|
+
'doc -> extract -> text'
|
|
2939
|
+
);
|
|
2940
|
+
assert(g1.isEquivalent(g2), 'Line-based and bracketed must produce equivalent graphs');
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function testMachine_lineBasedFormatSerialization() {
|
|
2944
|
+
const g = new Machine([
|
|
2945
|
+
new MachineEdge(
|
|
2946
|
+
[MediaUrn.fromString('media:pdf')],
|
|
2947
|
+
CapUrn.fromString('cap:in="media:pdf";op=extract;out="media:txt;textable"'),
|
|
2948
|
+
MediaUrn.fromString('media:txt;textable'),
|
|
2949
|
+
false
|
|
2950
|
+
),
|
|
2951
|
+
]);
|
|
2952
|
+
const lineBased = g.toMachineNotationFormatted('line-based');
|
|
2953
|
+
assert(!lineBased.includes('['), 'Line-based format must not contain brackets');
|
|
2954
|
+
assert(!lineBased.includes(']'), 'Line-based format must not contain brackets');
|
|
2955
|
+
assert(lineBased.includes('extract cap:'), 'Should contain header');
|
|
2956
|
+
assert(lineBased.includes('-> extract ->'), 'Should contain wiring');
|
|
2957
|
+
|
|
2958
|
+
// Round-trip
|
|
2959
|
+
const reparsed = Machine.fromString(lineBased);
|
|
2960
|
+
assert(g.isEquivalent(reparsed), 'Line-based round-trip must produce equivalent graph');
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function testMachine_lineBasedAndBracketedParseSameGraph() {
|
|
2964
|
+
const g = new Machine([
|
|
2965
|
+
new MachineEdge(
|
|
2966
|
+
[MediaUrn.fromString('media:pdf')],
|
|
2967
|
+
CapUrn.fromString('cap:in="media:pdf";op=extract;out="media:txt;textable"'),
|
|
2968
|
+
MediaUrn.fromString('media:txt;textable'),
|
|
2969
|
+
false
|
|
2970
|
+
),
|
|
2971
|
+
new MachineEdge(
|
|
2972
|
+
[MediaUrn.fromString('media:txt;textable')],
|
|
2973
|
+
CapUrn.fromString('cap:in="media:txt;textable";op=embed;out="media:embedding-vector;record;textable"'),
|
|
2974
|
+
MediaUrn.fromString('media:embedding-vector;record;textable'),
|
|
2975
|
+
false
|
|
2976
|
+
),
|
|
2977
|
+
]);
|
|
2978
|
+
const bracketed = g.toMachineNotationFormatted('bracketed');
|
|
2979
|
+
const lineBased = g.toMachineNotationFormatted('line-based');
|
|
2980
|
+
|
|
2981
|
+
const gBracketed = Machine.fromString(bracketed);
|
|
2982
|
+
const gLineBased = Machine.fromString(lineBased);
|
|
2983
|
+
assert(gBracketed.isEquivalent(gLineBased),
|
|
2984
|
+
'Bracketed and line-based must parse to equivalent graphs');
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2796
2987
|
// --- Machine graph tests (mirrors graph.rs tests) ---
|
|
2797
2988
|
|
|
2798
2989
|
function testMachine_edgeEquivalenceSameUrns() {
|
|
@@ -3719,6 +3910,9 @@ async function runTests() {
|
|
|
3719
3910
|
runTest('JS: get_extension_mappings', testJS_getExtensionMappings);
|
|
3720
3911
|
runTest('JS: cap_with_media_specs', testJS_capWithMediaSpecs);
|
|
3721
3912
|
runTest('JS: cap_json_serialization', testJS_capJSONSerialization);
|
|
3913
|
+
runTest('JS: cap_documentation_round_trip', testJS_capDocumentationRoundTrip);
|
|
3914
|
+
runTest('JS: cap_documentation_omitted_when_null', testJS_capDocumentationOmittedWhenNull);
|
|
3915
|
+
runTest('JS: media_spec_documentation_propagates_through_resolve', testJS_mediaSpecDocumentationPropagatesThroughResolve);
|
|
3722
3916
|
runTest('JS: stdin_source_kind_constants', testJS_stdinSourceKindConstants);
|
|
3723
3917
|
runTest('JS: stdin_source_null_data', testJS_stdinSourceNullData);
|
|
3724
3918
|
const p1 = runTest('JS: args_passed_to_executeCap', testJS_argsPassedToExecuteCap);
|
|
@@ -3808,6 +4002,17 @@ async function runTests() {
|
|
|
3808
4002
|
runTest('MACHINE:malformed_input_fails', testMachine_malformedInputFails);
|
|
3809
4003
|
runTest('MACHINE:unterminated_bracket_fails', testMachine_unterminatedBracketFails);
|
|
3810
4004
|
|
|
4005
|
+
// machine module: line-based mode tests
|
|
4006
|
+
console.log('\n--- machine/parser.rs (line-based) ---');
|
|
4007
|
+
runTest('MACHINE:line_based_simple_chain', testMachine_lineBasedSimpleChain);
|
|
4008
|
+
runTest('MACHINE:line_based_two_step_chain', testMachine_lineBasedTwoStepChain);
|
|
4009
|
+
runTest('MACHINE:line_based_loop', testMachine_lineBasedLoop);
|
|
4010
|
+
runTest('MACHINE:line_based_fan_in', testMachine_lineBasedFanIn);
|
|
4011
|
+
runTest('MACHINE:mixed_bracketed_and_line_based', testMachine_mixedBracketedAndLineBased);
|
|
4012
|
+
runTest('MACHINE:line_based_equivalent_to_bracketed', testMachine_lineBasedEquivalentToBracketed);
|
|
4013
|
+
runTest('MACHINE:line_based_format_serialization', testMachine_lineBasedFormatSerialization);
|
|
4014
|
+
runTest('MACHINE:line_based_and_bracketed_parse_same_graph', testMachine_lineBasedAndBracketedParseSameGraph);
|
|
4015
|
+
|
|
3811
4016
|
// machine module: graph tests (mirrors graph.rs)
|
|
3812
4017
|
console.log('\n--- machine/graph.rs ---');
|
|
3813
4018
|
runTest('MACHINE:edge_equivalence_same_urns', testMachine_edgeEquivalenceSameUrns);
|
package/machine.pegjs
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Machine notation grammar for Peggy.
|
|
2
2
|
//
|
|
3
3
|
// This grammar mirrors the Rust pest grammar in machine.pest exactly.
|
|
4
4
|
// All actions return location() for LSP position tracking.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Two equally valid statement forms:
|
|
7
|
+
//
|
|
8
|
+
// Bracketed (one or more statements per line, any layout):
|
|
7
9
|
// [extract cap:in="media:pdf";op=extract;out="media:txt;textable"]
|
|
8
10
|
// [doc -> extract -> text]
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
+
//
|
|
12
|
+
// Line-based (one statement per line, no brackets):
|
|
13
|
+
// extract cap:in="media:pdf";op=extract;out="media:txt;textable"
|
|
14
|
+
// doc -> extract -> text
|
|
15
|
+
// (thumbnail, model_spec) -> describe -> description
|
|
16
|
+
// pages -> LOOP p2t -> texts
|
|
17
|
+
//
|
|
18
|
+
// Both forms can be freely mixed in the same program.
|
|
11
19
|
|
|
12
20
|
program = _ stmts:stmt* _ { return stmts; }
|
|
13
21
|
|
|
14
22
|
stmt = "[" _ inner:inner _ "]" _ { return inner; }
|
|
23
|
+
/ inner:inner _ { return inner; }
|
|
15
24
|
|
|
16
25
|
inner = wiring / header
|
|
17
26
|
|
|
@@ -48,11 +57,14 @@ alias = $( [a-zA-Z_] [a-zA-Z0-9_-]* )
|
|
|
48
57
|
// Cap URN with location tracking
|
|
49
58
|
cap_urn_loc = c:cap_urn { return { value: c, location: location() }; }
|
|
50
59
|
|
|
51
|
-
// Cap URN: starts with "cap:", reads until
|
|
52
|
-
//
|
|
60
|
+
// Cap URN: starts with "cap:", reads until a statement terminator:
|
|
61
|
+
// "]" for bracketed mode, or newline for line-based mode.
|
|
62
|
+
// Quoted strings can contain "]" and newlines.
|
|
53
63
|
cap_urn = $( "cap:" cap_urn_body* )
|
|
54
64
|
|
|
55
|
-
cap_urn_body = quoted_value / ( !"]" . )
|
|
65
|
+
cap_urn_body = quoted_value / ( !"]" !NL . )
|
|
66
|
+
|
|
67
|
+
NL "newline" = "\r\n" / "\n" / "\r"
|
|
56
68
|
|
|
57
69
|
// Quoted strings support escaped quotes.
|
|
58
70
|
quoted_value = '"' ( '\\"' / '\\\\' / (!'"' .) )* '"'
|
package/package.json
CHANGED