capdag 0.100.226 → 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 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
 
@@ -1888,11 +1897,12 @@ class Cap {
1888
1897
  * @param {CapUrn} urn - The capability URN
1889
1898
  * @param {string} title - The human-readable title (required)
1890
1899
  * @param {string} command - The command string
1891
- * @param {string|null} capDescription - Optional description
1900
+ * @param {string|null} capDescription - Optional short plain-text description
1892
1901
  * @param {Object} metadata - Optional metadata object
1893
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.
1894
1904
  */
1895
- constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null) {
1905
+ constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
1896
1906
  if (!(urn instanceof CapUrn)) {
1897
1907
  throw new Error('URN must be a CapUrn instance');
1898
1908
  }
@@ -1907,6 +1917,7 @@ class Cap {
1907
1917
  this.title = title;
1908
1918
  this.command = command;
1909
1919
  this.cap_description = capDescription;
1920
+ this.documentation = documentation;
1910
1921
  this.metadata = metadata || {};
1911
1922
  this.mediaSpecs = []; // Media spec definitions array
1912
1923
  this.args = []; // Array of CapArg - unified argument format
@@ -1915,6 +1926,31 @@ class Cap {
1915
1926
  this.registered_by = null; // Registration attribution
1916
1927
  }
1917
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
+
1918
1954
  /**
1919
1955
  * Get the media type expected for stdin (derived from args with stdin source)
1920
1956
  * @returns {string|null} The media URN for stdin, or null if cap doesn't accept stdin
@@ -2098,6 +2134,7 @@ class Cap {
2098
2134
  this.title === other.title &&
2099
2135
  this.command === other.command &&
2100
2136
  this.cap_description === other.cap_description &&
2137
+ this.documentation === other.documentation &&
2101
2138
  JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
2102
2139
  JSON.stringify(this.mediaSpecs) === JSON.stringify(other.mediaSpecs) &&
2103
2140
  JSON.stringify(this.args.map(a => a.toJSON())) === JSON.stringify(other.args.map(a => a.toJSON())) &&
@@ -2122,6 +2159,12 @@ class Cap {
2122
2159
  output: this.output
2123
2160
  };
2124
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
+
2125
2168
  if (this.metadata_json !== null && this.metadata_json !== undefined) {
2126
2169
  result.metadata_json = this.metadata_json;
2127
2170
  }
@@ -2141,7 +2184,10 @@ class Cap {
2141
2184
  }
2142
2185
  const urn = CapUrn.fromString(json.urn);
2143
2186
 
2144
- const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json);
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);
2145
2191
  cap.mediaSpecs = json.media_specs || json.mediaSpecs || [];
2146
2192
  // Parse args (new format)
2147
2193
  if (json.args && Array.isArray(json.args)) {
@@ -4648,6 +4694,64 @@ class Machine {
4648
4694
  return lines.join('\n');
4649
4695
  }
4650
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
+
4651
4755
  /**
4652
4756
  * Build the alias map, node name map, and edge ordering for serialization.
4653
4757
  *
package/capdag.test.js CHANGED
@@ -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
- // Bracket-delimited machine notation grammar for Peggy.
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
- // Examples:
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
- // [(thumbnail, model_spec) -> describe -> description]
10
- // [pages -> LOOP p2t -> texts]
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 the statement-closing "]",
52
- // except quoted strings can contain "]".
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
@@ -37,5 +37,5 @@
37
37
  "pretest": "npm run build:parser",
38
38
  "test": "node capdag.test.js"
39
39
  },
40
- "version": "0.100.226"
40
+ "version": "0.102.232"
41
41
  }