capdag 0.100.226 → 0.104.240

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() {
@@ -3552,6 +3743,595 @@ function assertThrowsWithCode(fn, expectedCode) {
3552
3743
  }
3553
3744
  }
3554
3745
 
3746
+ // ============================================================================
3747
+ // cap-graph-renderer helpers — pure functions that do not require a DOM.
3748
+ // The renderer class itself needs cytoscape + DOM and is exercised by hand
3749
+ // in the browser; these tests cover the pure data transforms underneath it.
3750
+ // ============================================================================
3751
+
3752
+ const {
3753
+ cardinalityLabel: rendererCardinalityLabel,
3754
+ cardinalityFromCap: rendererCardinalityFromCap,
3755
+ canonicalMediaUrn: rendererCanonicalMediaUrn,
3756
+ mediaNodeLabel: rendererMediaNodeLabel,
3757
+ buildStrandGraphData: rendererBuildStrandGraphData,
3758
+ buildRunGraphData: rendererBuildRunGraphData,
3759
+ buildMachineGraphData: rendererBuildMachineGraphData,
3760
+ classifyStrandCapSteps: rendererClassifyStrandCapSteps,
3761
+ validateStrandPayload: rendererValidateStrandPayload,
3762
+ validateRunPayload: rendererValidateRunPayload,
3763
+ validateMachinePayload: rendererValidateMachinePayload,
3764
+ validateStrandStep: rendererValidateStrandStep,
3765
+ validateBodyOutcome: rendererValidateBodyOutcome,
3766
+ } = require('./cap-graph-renderer.js');
3767
+
3768
+ // The renderer module reads its dependencies off `window` or `global` at
3769
+ // call time (it is browser-first). Node has no window, so we install the
3770
+ // needed capdag-js classes on `global` before the tests run. Every
3771
+ // renderer path exercised by the tests resolves through these.
3772
+ if (typeof global.TaggedUrn === 'undefined') {
3773
+ global.TaggedUrn = require('tagged-urn').TaggedUrn;
3774
+ }
3775
+ if (typeof global.MediaUrn === 'undefined') global.MediaUrn = MediaUrn;
3776
+ if (typeof global.CapUrn === 'undefined') global.CapUrn = CapUrn;
3777
+ if (typeof global.Cap === 'undefined') global.Cap = Cap;
3778
+ if (typeof global.CapGraph === 'undefined') global.CapGraph = CapGraph;
3779
+ // Reference the top-of-file destructured createCap via the module export.
3780
+ if (typeof global.createCap === 'undefined') {
3781
+ global.createCap = require('./capdag.js').createCap;
3782
+ }
3783
+
3784
+ function testRenderer_cardinalityLabel_allFourCases() {
3785
+ assertEqual(rendererCardinalityLabel(false, false), '1\u21921', 'scalar-to-scalar');
3786
+ assertEqual(rendererCardinalityLabel(true, false), 'n\u21921', 'sequence-to-scalar');
3787
+ assertEqual(rendererCardinalityLabel(false, true), '1\u2192n', 'scalar-to-sequence');
3788
+ assertEqual(rendererCardinalityLabel(true, true), 'n\u2192n', 'sequence-to-sequence');
3789
+ }
3790
+
3791
+ function testRenderer_cardinalityLabel_usesUnicodeArrow() {
3792
+ // The label must use the real rightwards arrow (U+2192), not ASCII "->".
3793
+ // Downstream styling and tests depend on this glyph.
3794
+ const label = rendererCardinalityLabel(false, true);
3795
+ assert(label.includes('\u2192'), 'label should contain U+2192 rightwards arrow');
3796
+ assert(!label.includes('->'), 'label must not contain the ASCII replacement "->"');
3797
+ }
3798
+
3799
+ function testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg() {
3800
+ // The main input arg is the one whose sources include a stdin source.
3801
+ // A naive implementation that reads args[0] would see `cli-only` (not a
3802
+ // sequence) and report 1→1 even though the stdin arg is a sequence.
3803
+ const cap = {
3804
+ urn: 'cap:in="media:textable;list";op=transcribe;out="media:textable"',
3805
+ args: [
3806
+ {
3807
+ display_name: 'cli-only',
3808
+ is_sequence: false,
3809
+ sources: [{ cli_flag: '--mode' }],
3810
+ },
3811
+ {
3812
+ display_name: 'main-input',
3813
+ is_sequence: true,
3814
+ sources: [{ stdin: {} }],
3815
+ },
3816
+ ],
3817
+ output: { is_sequence: false },
3818
+ };
3819
+ assertEqual(rendererCardinalityFromCap(cap), 'n\u21921',
3820
+ 'must pick the arg that has a stdin source, not args[0]');
3821
+ }
3822
+
3823
+ function testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing() {
3824
+ // No args and no output: both sides collapse to 1 (scalar default).
3825
+ // If a bug makes the function return "n" for missing data, this fails.
3826
+ const cap = { urn: 'cap:in="media:";op=noop;out="media:"' };
3827
+ assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
3828
+ 'missing args/output must default to scalar on both sides');
3829
+ }
3830
+
3831
+ function testRenderer_cardinalityFromCap_outputOnlySequence() {
3832
+ // One scalar stdin arg, output is a sequence: expects 1→n.
3833
+ const cap = {
3834
+ urn: 'cap:in="media:textable";op=generate;out="media:textable;list"',
3835
+ args: [{ sources: [{ stdin: {} }], is_sequence: false }],
3836
+ output: { is_sequence: true },
3837
+ };
3838
+ assertEqual(rendererCardinalityFromCap(cap), '1\u2192n',
3839
+ 'scalar stdin with sequence output must yield 1→n');
3840
+ }
3841
+
3842
+ function testRenderer_cardinalityFromCap_rejectsStringIsSequence() {
3843
+ // The function must use strict `=== true` to avoid treating truthy strings
3844
+ // as booleans. "true" is a string, not a boolean — it must NOT be treated
3845
+ // as a sequence, because downstream renderers expect boolean semantics.
3846
+ const cap = {
3847
+ urn: 'cap:in="media:";op=x;out="media:"',
3848
+ args: [{ sources: [{ stdin: {} }], is_sequence: 'true' }],
3849
+ output: { is_sequence: 'true' },
3850
+ };
3851
+ assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
3852
+ 'string "true" must not be treated as boolean true');
3853
+ }
3854
+
3855
+ function testRenderer_cardinalityFromCap_throwsOnNonObject() {
3856
+ // Fail-hard on invalid input; no fallback to a default cardinality.
3857
+ let threw = false;
3858
+ try {
3859
+ rendererCardinalityFromCap(null);
3860
+ } catch (e) {
3861
+ threw = true;
3862
+ }
3863
+ assert(threw, 'cardinalityFromCap(null) must throw');
3864
+
3865
+ threw = false;
3866
+ try {
3867
+ rendererCardinalityFromCap('not-an-object');
3868
+ } catch (e) {
3869
+ threw = true;
3870
+ }
3871
+ assert(threw, 'cardinalityFromCap(string) must throw');
3872
+ }
3873
+
3874
+ function testRenderer_canonicalMediaUrn_normalizesTagOrder() {
3875
+ // Two media URNs with identical tags in different input orders must
3876
+ // produce the same canonical string. If canonicalization is bypassed,
3877
+ // the two strings remain different and this test exposes it.
3878
+ const a = rendererCanonicalMediaUrn('media:video;h264;list');
3879
+ const b = rendererCanonicalMediaUrn('media:list;h264;video');
3880
+ assertEqual(a, b, 'tag-order differences must not survive canonicalization');
3881
+ }
3882
+
3883
+ function testRenderer_canonicalMediaUrn_preservesValueTags() {
3884
+ const c = rendererCanonicalMediaUrn('media:model;quant=q4');
3885
+ assert(c.includes('quant=q4'), 'value tag must be preserved');
3886
+ }
3887
+
3888
+ function testRenderer_canonicalMediaUrn_rejectsCapUrn() {
3889
+ // MediaUrn.fromString enforces the media: prefix. Feeding a cap URN to
3890
+ // canonicalMediaUrn must fail hard.
3891
+ let threw = false;
3892
+ try {
3893
+ rendererCanonicalMediaUrn('cap:op=x;in="media:";out="media:"');
3894
+ } catch (e) {
3895
+ threw = true;
3896
+ }
3897
+ assert(threw, 'canonicalMediaUrn must reject non-media URNs');
3898
+ }
3899
+
3900
+ function testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker() {
3901
+ // A media URN with one value tag and one marker tag renders two lines:
3902
+ // value tag as "key: value", marker tag as bare key. Order is canonical
3903
+ // (alphabetical, matching TaggedUrn's sorted key iteration).
3904
+ const label = rendererMediaNodeLabel('media:video;quant=q4');
3905
+ const lines = label.split('\n');
3906
+ assertEqual(lines.length, 2, 'two tags must produce two lines');
3907
+ assert(lines.includes('quant: q4'), 'value tag rendered as key: value');
3908
+ assert(lines.includes('video'), 'marker tag rendered as bare key');
3909
+ }
3910
+
3911
+ function testRenderer_mediaNodeLabel_stableAcrossTagOrder() {
3912
+ // Labels must be tag-order-independent so that the same media URN
3913
+ // produces the same multi-line label regardless of how the source
3914
+ // happened to spell it.
3915
+ const a = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:list;textable'));
3916
+ const b = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:textable;list'));
3917
+ assertEqual(a, b, 'label must be stable across tag orderings');
3918
+ }
3919
+
3920
+ // ---------------- strand builder ----------------
3921
+
3922
+ function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
3923
+ return {
3924
+ step_type: {
3925
+ Cap: {
3926
+ cap_urn: capUrn,
3927
+ title,
3928
+ specificity: 0,
3929
+ input_is_sequence: inSeq,
3930
+ output_is_sequence: outSeq,
3931
+ },
3932
+ },
3933
+ from_spec: fromSpec,
3934
+ to_spec: toSpec,
3935
+ };
3936
+ }
3937
+
3938
+ function makeForEachStep(mediaSpec) {
3939
+ return {
3940
+ step_type: { ForEach: { media_spec: mediaSpec } },
3941
+ from_spec: mediaSpec,
3942
+ to_spec: mediaSpec,
3943
+ };
3944
+ }
3945
+
3946
+ function makeCollectStep(mediaSpec) {
3947
+ return {
3948
+ step_type: { Collect: { media_spec: mediaSpec } },
3949
+ from_spec: mediaSpec,
3950
+ to_spec: mediaSpec,
3951
+ };
3952
+ }
3953
+
3954
+ function testRenderer_validateStrandStep_rejectsUnknownVariant() {
3955
+ // A step with an unknown variant must fail hard at validation; no
3956
+ // silent coercion.
3957
+ let threw = false;
3958
+ try {
3959
+ rendererValidateStrandStep({
3960
+ step_type: { WrongVariant: {} },
3961
+ from_spec: 'media:a',
3962
+ to_spec: 'media:a',
3963
+ }, 'test');
3964
+ } catch (e) {
3965
+ threw = true;
3966
+ assert(e.message.includes('WrongVariant'), 'error must name the bad variant');
3967
+ }
3968
+ assert(threw, 'unknown variant must throw');
3969
+ }
3970
+
3971
+ function testRenderer_validateStrandStep_requiresBooleanIsSequence() {
3972
+ // A Cap variant must have boolean is_sequence fields; number or string
3973
+ // must reject.
3974
+ let threw = false;
3975
+ try {
3976
+ rendererValidateStrandStep({
3977
+ step_type: { Cap: {
3978
+ cap_urn: 'cap:in="media:a";op=x;out="media:b"',
3979
+ title: 't',
3980
+ input_is_sequence: 1, // number, not boolean
3981
+ output_is_sequence: false,
3982
+ }},
3983
+ from_spec: 'media:a',
3984
+ to_spec: 'media:b',
3985
+ }, 'test');
3986
+ } catch (e) {
3987
+ threw = true;
3988
+ assert(e.message.includes('input_is_sequence'), 'error must name the bad field');
3989
+ }
3990
+ assert(threw, 'non-boolean is_sequence must throw');
3991
+ }
3992
+
3993
+ function testRenderer_classifyStrandCapSteps_capFlags() {
3994
+ // Strand: ForEach → cap1 → cap2 → cap3 → Collect. cap1 should have
3995
+ // prevForEach=true; cap3 should have nextCollect=true; cap2 should
3996
+ // have neither.
3997
+ const steps = [
3998
+ makeForEachStep('media:pdf;list'),
3999
+ makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
4000
+ makeCapStep('cap:in="media:png";op=b;out="media:jpg"', 'b', 'media:png', 'media:jpg', false, false),
4001
+ makeCapStep('cap:in="media:jpg";op=c;out="media:txt"', 'c', 'media:jpg', 'media:txt', false, false),
4002
+ makeCollectStep('media:txt'),
4003
+ ];
4004
+ const { capStepIndices, capFlags } = rendererClassifyStrandCapSteps(steps);
4005
+ assertEqual(capStepIndices.length, 3, 'three cap steps');
4006
+ assert(capFlags.get(1).prevForEach, 'cap1 has prevForEach');
4007
+ assert(!capFlags.get(1).nextCollect, 'cap1 has no nextCollect');
4008
+ assert(!capFlags.get(2).prevForEach, 'cap2 has no prevForEach');
4009
+ assert(!capFlags.get(2).nextCollect, 'cap2 has no nextCollect');
4010
+ assert(!capFlags.get(3).prevForEach, 'cap3 has no prevForEach');
4011
+ assert(capFlags.get(3).nextCollect, 'cap3 has nextCollect');
4012
+ }
4013
+
4014
+ function testRenderer_classifyStrandCapSteps_nestedForks() {
4015
+ // Nested strand: ForEach → cap1 → ForEach → cap2 → Collect → cap3 → Collect.
4016
+ // cap1 has prevForEach (outer), cap2 has prevForEach (inner) and
4017
+ // nextCollect (inner), cap3 has nextCollect (outer).
4018
+ const steps = [
4019
+ makeForEachStep('media:a;list'),
4020
+ makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4021
+ makeForEachStep('media:b;list'),
4022
+ makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4023
+ makeCollectStep('media:c'),
4024
+ makeCapStep('cap:in="media:c";op=c;out="media:d"', 'c', 'media:c', 'media:d', false, false),
4025
+ makeCollectStep('media:d'),
4026
+ ];
4027
+ const { capFlags } = rendererClassifyStrandCapSteps(steps);
4028
+ assert(capFlags.get(1).prevForEach && !capFlags.get(1).nextCollect, 'cap1 outer entry');
4029
+ assert(capFlags.get(3).prevForEach && capFlags.get(3).nextCollect, 'cap2 inner both');
4030
+ assert(!capFlags.get(5).prevForEach && capFlags.get(5).nextCollect, 'cap3 outer exit');
4031
+ }
4032
+
4033
+ function testRenderer_buildStrandGraphData_labelsForkBoundaries() {
4034
+ // End-to-end: a foreach strand in which source_spec (media:pdf;list)
4035
+ // differs from the first cap's from_spec (media:pdf) produces an
4036
+ // explicit fix-up edge source→firstCap.from labeled "for each". The
4037
+ // cap edge carries only the cap title. The topology is
4038
+ // media:pdf;list --[for each]--> media:pdf --[extract text]--> media:txt
4039
+ // Only two edges — ForEach is NOT a separate node, Collect is absent
4040
+ // because the strand's target_spec already matches the last cap's
4041
+ // to_spec (no fan-in fix-up needed).
4042
+ const payload = {
4043
+ source_spec: 'media:pdf;list',
4044
+ target_spec: 'media:txt',
4045
+ steps: [
4046
+ makeForEachStep('media:pdf;list'),
4047
+ makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract text', 'media:pdf', 'media:txt', false, false),
4048
+ ],
4049
+ };
4050
+ const built = rendererBuildStrandGraphData(payload);
4051
+ assertEqual(built.edges.length, 2, 'one fix-up edge + one cap edge');
4052
+ const forEachEdge = built.edges[0];
4053
+ assertEqual(forEachEdge.label, 'for each', 'first edge is the for-each fan-out');
4054
+ assertEqual(forEachEdge.source, 'media:pdf;list', 'for-each sources from source_spec');
4055
+ assertEqual(forEachEdge.target, 'media:pdf', 'for-each targets first cap.from_spec');
4056
+ const capEdge = built.edges[1];
4057
+ assertEqual(capEdge.label, 'extract text', 'cap edge carries only the cap title');
4058
+ assertEqual(capEdge.source, 'media:pdf', 'cap sources from its from_spec');
4059
+ assertEqual(capEdge.target, 'media:txt', 'cap targets its to_spec');
4060
+ }
4061
+
4062
+ function testRenderer_buildStrandGraphData_collectFixupEdge() {
4063
+ // When the strand's target_spec (media:pdf;list) differs from the
4064
+ // last cap's to_spec (media:pdf), the builder emits an explicit
4065
+ // fix-up edge lastCap.to → target labeled "collect".
4066
+ const payload = {
4067
+ source_spec: 'media:a',
4068
+ target_spec: 'media:pdf;list',
4069
+ steps: [
4070
+ makeCapStep('cap:in="media:a";op=x;out="media:pdf"', 'x', 'media:a', 'media:pdf', false, false),
4071
+ makeCollectStep('media:pdf'),
4072
+ ],
4073
+ };
4074
+ const built = rendererBuildStrandGraphData(payload);
4075
+ assertEqual(built.edges.length, 2, 'one cap edge + one collect fix-up');
4076
+ const capEdge = built.edges[0];
4077
+ assertEqual(capEdge.label, 'x', 'cap edge plain title');
4078
+ const collectEdge = built.edges[1];
4079
+ assertEqual(collectEdge.label, 'collect', 'collect fix-up labeled "collect"');
4080
+ assertEqual(collectEdge.source, 'media:pdf', 'collect sources from last cap.to_spec');
4081
+ assertEqual(collectEdge.target, 'media:pdf;list', 'collect targets target_spec');
4082
+ }
4083
+
4084
+ function testRenderer_buildStrandGraphData_plainCapNoMarker() {
4085
+ // A plain 1→1 cap step (no ForEach/Collect adjacency, no sequence)
4086
+ // must not emit any cardinality marker in the label. This catches a
4087
+ // regression where "1→1" was accidentally appended to every edge.
4088
+ const payload = {
4089
+ source_spec: 'media:a',
4090
+ target_spec: 'media:b',
4091
+ steps: [
4092
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4093
+ ],
4094
+ };
4095
+ const built = rendererBuildStrandGraphData(payload);
4096
+ assertEqual(built.edges.length, 1, 'one edge');
4097
+ assertEqual(built.edges[0].label, 'x', 'plain cap has no cardinality suffix');
4098
+ }
4099
+
4100
+ function testRenderer_buildStrandGraphData_sequenceShowsCardinality() {
4101
+ // A cap with input_is_sequence=true MUST emit "(n→1)" on the label.
4102
+ const payload = {
4103
+ source_spec: 'media:a;list',
4104
+ target_spec: 'media:b',
4105
+ steps: [
4106
+ makeCapStep('cap:in="media:a;list";op=x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
4107
+ ],
4108
+ };
4109
+ const built = rendererBuildStrandGraphData(payload);
4110
+ assertEqual(built.edges.length, 1, 'one edge');
4111
+ assert(built.edges[0].label.includes('(n\u21921)'),
4112
+ `edge label must include (n\u21921) marker; got: ${built.edges[0].label}`);
4113
+ }
4114
+
4115
+ function testRenderer_validateStrandPayload_missingSourceSpec() {
4116
+ let threw = false;
4117
+ try {
4118
+ rendererValidateStrandPayload({ target_spec: 'media:b', steps: [] });
4119
+ } catch (e) {
4120
+ threw = true;
4121
+ assert(e.message.includes('source_spec'), 'error must name source_spec');
4122
+ }
4123
+ assert(threw, 'missing source_spec must throw');
4124
+ }
4125
+
4126
+ // ---------------- run builder ----------------
4127
+
4128
+ function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
4129
+ let threw = false;
4130
+ try {
4131
+ rendererValidateBodyOutcome({ body_index: -1, success: true, cap_urns: [] }, 'test');
4132
+ } catch (e) {
4133
+ threw = true;
4134
+ }
4135
+ assert(threw, 'negative body_index must throw');
4136
+ }
4137
+
4138
+ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
4139
+ // 6 successes, 4 failures. visible_success_count=3, visible_failure_count=2,
4140
+ // total_body_count=10. The builder must:
4141
+ // - render exactly 3 success replicas (one node per cap step per body)
4142
+ // - render exactly 2 failure replicas
4143
+ // - emit a success show-more node with hidden count 3
4144
+ // - emit a failure show-more node with hidden count 2
4145
+ const strand = {
4146
+ source_spec: 'media:pdf;list',
4147
+ target_spec: 'media:txt',
4148
+ steps: [
4149
+ makeForEachStep('media:pdf;list'),
4150
+ makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
4151
+ makeCapStep('cap:in="media:png";op=b;out="media:txt"', 'b', 'media:png', 'media:txt', false, false),
4152
+ makeCollectStep('media:txt'),
4153
+ ],
4154
+ };
4155
+ const outcomes = [];
4156
+ for (let i = 0; i < 6; i++) {
4157
+ outcomes.push({ body_index: i, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 });
4158
+ }
4159
+ for (let i = 6; i < 10; i++) {
4160
+ outcomes.push({
4161
+ body_index: i,
4162
+ success: false,
4163
+ cap_urns: [],
4164
+ saved_paths: [],
4165
+ total_bytes: 0,
4166
+ duration_ms: 0,
4167
+ failed_cap: 'cap:in="media:png";op=b;out="media:txt"',
4168
+ error: 'oom',
4169
+ });
4170
+ }
4171
+ const payload = {
4172
+ resolved_strand: strand,
4173
+ body_outcomes: outcomes,
4174
+ visible_success_count: 3,
4175
+ visible_failure_count: 2,
4176
+ total_body_count: 10,
4177
+ };
4178
+ const built = rendererBuildRunGraphData(payload);
4179
+
4180
+ // Count replica nodes by classes.
4181
+ let successNodes = 0;
4182
+ let failureNodes = 0;
4183
+ for (const n of built.replicaNodes) {
4184
+ if (n.classes === 'body-success') successNodes++;
4185
+ if (n.classes === 'body-failure') failureNodes++;
4186
+ }
4187
+ assertEqual(successNodes, 3 * 2, 'three successful bodies × two cap steps each = 6 success nodes');
4188
+ // Failed bodies truncate at failed_cap (cap b = second cap), so trace
4189
+ // length includes both cap a and cap b → 2 nodes per failed body.
4190
+ assertEqual(failureNodes, 2 * 2, 'two failed bodies × two nodes each (trace truncated at failed_cap)');
4191
+
4192
+ // Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
4193
+ const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
4194
+ const failureShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'failure');
4195
+ assert(successShowMore !== undefined, 'success show-more present');
4196
+ assert(failureShowMore !== undefined, 'failure show-more present');
4197
+ assertEqual(successShowMore.data.hiddenCount, 3, 'success hidden count = 3');
4198
+ assertEqual(failureShowMore.data.hiddenCount, 2, 'failure hidden count = 2');
4199
+ }
4200
+
4201
+ function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
4202
+ // A failure without failed_cap (e.g. infrastructure failure) must still
4203
+ // render the full body trace — the builder must not crash or produce
4204
+ // zero replicas.
4205
+ const strand = {
4206
+ source_spec: 'media:pdf;list',
4207
+ target_spec: 'media:txt',
4208
+ steps: [
4209
+ makeForEachStep('media:pdf;list'),
4210
+ makeCapStep('cap:in="media:pdf";op=a;out="media:txt"', 'a', 'media:pdf', 'media:txt', false, false),
4211
+ makeCollectStep('media:txt'),
4212
+ ],
4213
+ };
4214
+ const payload = {
4215
+ resolved_strand: strand,
4216
+ body_outcomes: [
4217
+ { body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, error: 'unknown' },
4218
+ ],
4219
+ visible_success_count: 0,
4220
+ visible_failure_count: 5,
4221
+ total_body_count: 1,
4222
+ };
4223
+ const built = rendererBuildRunGraphData(payload);
4224
+ let failureNodes = 0;
4225
+ for (const n of built.replicaNodes) {
4226
+ if (n.classes === 'body-failure') failureNodes++;
4227
+ }
4228
+ assertEqual(failureNodes, 1, 'full trace (one cap) renders as one failure node');
4229
+ }
4230
+
4231
+ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
4232
+ // The renderer matches failed_cap against step cap URNs via
4233
+ // CapUrn.isEquivalent, NOT string equality. Feed a payload where
4234
+ // failed_cap and the step's cap_urn differ only in tag order — they
4235
+ // should still match, proving URNs are not treated as strings.
4236
+ const strand = {
4237
+ source_spec: 'media:a',
4238
+ target_spec: 'media:c',
4239
+ steps: [
4240
+ makeForEachStep('media:a;list'),
4241
+ // Canonical form places tags alphabetically: op after in/out.
4242
+ makeCapStep(
4243
+ 'cap:in="media:a";op=x;out="media:b"',
4244
+ 'x', 'media:a', 'media:b', false, false
4245
+ ),
4246
+ makeCapStep(
4247
+ 'cap:in="media:b";op=y;out="media:c"',
4248
+ 'y', 'media:b', 'media:c', false, false
4249
+ ),
4250
+ makeCollectStep('media:c'),
4251
+ ],
4252
+ };
4253
+ // The failed_cap URN is semantically the same as step 1 (cap y). If
4254
+ // CapUrn.fromString canonicalizes (it should), any equivalent form
4255
+ // will match. Feed a fully-specified form that is equivalent.
4256
+ const payload = {
4257
+ resolved_strand: strand,
4258
+ body_outcomes: [
4259
+ {
4260
+ body_index: 0,
4261
+ success: false,
4262
+ cap_urns: [],
4263
+ saved_paths: [],
4264
+ total_bytes: 0,
4265
+ duration_ms: 0,
4266
+ failed_cap: 'cap:in="media:b";out="media:c";op=y', // different tag order
4267
+ error: 'fail',
4268
+ },
4269
+ ],
4270
+ visible_success_count: 0,
4271
+ visible_failure_count: 1,
4272
+ total_body_count: 1,
4273
+ };
4274
+ const built = rendererBuildRunGraphData(payload);
4275
+ let failureNodes = 0;
4276
+ for (const n of built.replicaNodes) {
4277
+ if (n.classes === 'body-failure') failureNodes++;
4278
+ }
4279
+ // cap x + cap y = 2 nodes for a body trace that terminates at cap y.
4280
+ assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
4281
+ }
4282
+
4283
+ // ---------------- machine builder ----------------
4284
+
4285
+ function testRenderer_validateMachinePayload_rejectsUnknownKind() {
4286
+ let threw = false;
4287
+ try {
4288
+ rendererValidateMachinePayload({
4289
+ elements: [{ kind: 'widget', graph_id: 'w1' }],
4290
+ });
4291
+ } catch (e) {
4292
+ threw = true;
4293
+ assert(e.message.includes('widget') || e.message.includes('kind'),
4294
+ 'error must name the bad kind');
4295
+ }
4296
+ assert(threw, 'unknown element kind must throw');
4297
+ }
4298
+
4299
+ function testRenderer_buildMachineGraphData_preservesTokenIds() {
4300
+ // Token IDs are the bridge for editor cross-highlighting. Every
4301
+ // element MUST carry its tokenId through into the cytoscape data.
4302
+ const data = {
4303
+ elements: [
4304
+ { kind: 'node', graph_id: 'n1', label: 'a', token_id: 't-node-a' },
4305
+ { kind: 'cap', graph_id: 'c1', label: 'fn', token_id: 't-cap-fn' },
4306
+ { kind: 'edge', graph_id: 'e1', source_graph_id: 'n1', target_graph_id: 'c1', label: '', token_id: 't-edge-1' },
4307
+ ],
4308
+ };
4309
+ const built = rendererBuildMachineGraphData(data);
4310
+ const nodeTokens = built.nodes.map(n => n.data.tokenId).sort();
4311
+ assertEqual(JSON.stringify(nodeTokens), JSON.stringify(['t-cap-fn', 't-node-a']),
4312
+ 'node tokenIds must round-trip');
4313
+ assertEqual(built.edges.length, 1, 'one edge');
4314
+ assertEqual(built.edges[0].data.tokenId, 't-edge-1', 'edge tokenId must round-trip');
4315
+ // Kinds are carried as element data for editor-side filtering.
4316
+ const kinds = built.nodes.map(n => n.data.kind).sort();
4317
+ assertEqual(JSON.stringify(kinds), JSON.stringify(['cap', 'node']),
4318
+ 'element kinds must be preserved');
4319
+ }
4320
+
4321
+ function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
4322
+ let threw = false;
4323
+ try {
4324
+ rendererBuildMachineGraphData({
4325
+ elements: [
4326
+ { kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
4327
+ ],
4328
+ });
4329
+ } catch (e) {
4330
+ threw = true;
4331
+ }
4332
+ assert(threw, 'edge without source_graph_id must throw');
4333
+ }
4334
+
3555
4335
  // ============================================================================
3556
4336
  // Test runner
3557
4337
  // ============================================================================
@@ -3719,6 +4499,9 @@ async function runTests() {
3719
4499
  runTest('JS: get_extension_mappings', testJS_getExtensionMappings);
3720
4500
  runTest('JS: cap_with_media_specs', testJS_capWithMediaSpecs);
3721
4501
  runTest('JS: cap_json_serialization', testJS_capJSONSerialization);
4502
+ runTest('JS: cap_documentation_round_trip', testJS_capDocumentationRoundTrip);
4503
+ runTest('JS: cap_documentation_omitted_when_null', testJS_capDocumentationOmittedWhenNull);
4504
+ runTest('JS: media_spec_documentation_propagates_through_resolve', testJS_mediaSpecDocumentationPropagatesThroughResolve);
3722
4505
  runTest('JS: stdin_source_kind_constants', testJS_stdinSourceKindConstants);
3723
4506
  runTest('JS: stdin_source_null_data', testJS_stdinSourceNullData);
3724
4507
  const p1 = runTest('JS: args_passed_to_executeCap', testJS_argsPassedToExecuteCap);
@@ -3808,6 +4591,17 @@ async function runTests() {
3808
4591
  runTest('MACHINE:malformed_input_fails', testMachine_malformedInputFails);
3809
4592
  runTest('MACHINE:unterminated_bracket_fails', testMachine_unterminatedBracketFails);
3810
4593
 
4594
+ // machine module: line-based mode tests
4595
+ console.log('\n--- machine/parser.rs (line-based) ---');
4596
+ runTest('MACHINE:line_based_simple_chain', testMachine_lineBasedSimpleChain);
4597
+ runTest('MACHINE:line_based_two_step_chain', testMachine_lineBasedTwoStepChain);
4598
+ runTest('MACHINE:line_based_loop', testMachine_lineBasedLoop);
4599
+ runTest('MACHINE:line_based_fan_in', testMachine_lineBasedFanIn);
4600
+ runTest('MACHINE:mixed_bracketed_and_line_based', testMachine_mixedBracketedAndLineBased);
4601
+ runTest('MACHINE:line_based_equivalent_to_bracketed', testMachine_lineBasedEquivalentToBracketed);
4602
+ runTest('MACHINE:line_based_format_serialization', testMachine_lineBasedFormatSerialization);
4603
+ runTest('MACHINE:line_based_and_bracketed_parse_same_graph', testMachine_lineBasedAndBracketedParseSameGraph);
4604
+
3811
4605
  // machine module: graph tests (mirrors graph.rs)
3812
4606
  console.log('\n--- machine/graph.rs ---');
3813
4607
  runTest('MACHINE:edge_equivalence_same_urns', testMachine_edgeEquivalenceSameUrns);
@@ -3888,6 +4682,43 @@ async function runTests() {
3888
4682
  runTest('REGISTRY: capRegistryClient_construction', testMachine_capRegistryClient_construction);
3889
4683
  runTest('REGISTRY: capRegistryEntry_defaults', testMachine_capRegistryEntry_defaults);
3890
4684
 
4685
+ // cap-graph-renderer pure helpers (no DOM dependency)
4686
+ console.log('\n--- cap-graph-renderer helpers ---');
4687
+ runTest('RENDERER: cardinalityLabel_allFourCases', testRenderer_cardinalityLabel_allFourCases);
4688
+ runTest('RENDERER: cardinalityLabel_usesUnicodeArrow', testRenderer_cardinalityLabel_usesUnicodeArrow);
4689
+ runTest('RENDERER: cardinalityFromCap_findsStdinArg', testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg);
4690
+ runTest('RENDERER: cardinalityFromCap_scalarDefaults', testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing);
4691
+ runTest('RENDERER: cardinalityFromCap_outputOnlySequence', testRenderer_cardinalityFromCap_outputOnlySequence);
4692
+ runTest('RENDERER: cardinalityFromCap_rejectsStringBool', testRenderer_cardinalityFromCap_rejectsStringIsSequence);
4693
+ runTest('RENDERER: cardinalityFromCap_throwsOnNonObject', testRenderer_cardinalityFromCap_throwsOnNonObject);
4694
+ runTest('RENDERER: canonicalMediaUrn_normalizesTagOrder', testRenderer_canonicalMediaUrn_normalizesTagOrder);
4695
+ runTest('RENDERER: canonicalMediaUrn_preservesValueTags', testRenderer_canonicalMediaUrn_preservesValueTags);
4696
+ runTest('RENDERER: canonicalMediaUrn_rejectsCapUrn', testRenderer_canonicalMediaUrn_rejectsCapUrn);
4697
+ runTest('RENDERER: mediaNodeLabel_valueAndMarker', testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker);
4698
+ runTest('RENDERER: mediaNodeLabel_stableAcrossTagOrder', testRenderer_mediaNodeLabel_stableAcrossTagOrder);
4699
+
4700
+ console.log('\n--- cap-graph-renderer strand builder ---');
4701
+ runTest('RENDERER: validateStrandStep_unknownVariant', testRenderer_validateStrandStep_rejectsUnknownVariant);
4702
+ runTest('RENDERER: validateStrandStep_booleanIsSequence', testRenderer_validateStrandStep_requiresBooleanIsSequence);
4703
+ runTest('RENDERER: classifyStrandCapSteps_simple', testRenderer_classifyStrandCapSteps_capFlags);
4704
+ runTest('RENDERER: classifyStrandCapSteps_nested', testRenderer_classifyStrandCapSteps_nestedForks);
4705
+ runTest('RENDERER: buildStrand_labelsForkBoundaries', testRenderer_buildStrandGraphData_labelsForkBoundaries);
4706
+ runTest('RENDERER: buildStrand_collectFixupEdge', testRenderer_buildStrandGraphData_collectFixupEdge);
4707
+ runTest('RENDERER: buildStrand_plainCapNoMarker', testRenderer_buildStrandGraphData_plainCapNoMarker);
4708
+ runTest('RENDERER: buildStrand_sequenceShowsCardinality', testRenderer_buildStrandGraphData_sequenceShowsCardinality);
4709
+ runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
4710
+
4711
+ console.log('\n--- cap-graph-renderer run builder ---');
4712
+ runTest('RENDERER: validateBodyOutcome_negativeIndex', testRenderer_validateBodyOutcome_rejectsNegativeIndex);
4713
+ runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
4714
+ runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
4715
+ runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
4716
+
4717
+ console.log('\n--- cap-graph-renderer machine builder ---');
4718
+ runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
4719
+ runTest('RENDERER: buildMachine_preservesTokenIds', testRenderer_buildMachineGraphData_preservesTokenIds);
4720
+ runTest('RENDERER: buildMachine_rejectsEdgeMissingSource', testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource);
4721
+
3891
4722
  // Summary
3892
4723
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
3893
4724
  if (failCount > 0) {
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.104.240"
41
41
  }