capdag 1.222.603 → 1.233.649

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.test.js CHANGED
@@ -126,14 +126,14 @@ function test6204_Urn(tags) {
126
126
  // Helper to create a Cap for testing
127
127
  function makeCap(urnString, title) {
128
128
  const capUrn = CapUrn.fromString(urnString);
129
- return new Cap(capUrn, title, 'test', title);
129
+ return new Cap(capUrn, title, ['test'], title);
130
130
  }
131
131
 
132
132
  // Helper to create caps with specific in/out media URNs for graph testing
133
133
  function makeGraphCap(inUrn, outUrn, title) {
134
134
  const urnString = `cap:in="${inUrn}";convert;out="${outUrn}"`;
135
135
  const capUrn = CapUrn.fromString(urnString);
136
- return new Cap(capUrn, title, 'convert', title);
136
+ return new Cap(capUrn, title, ['convert'], title);
137
137
  }
138
138
 
139
139
  // ============================================================================
@@ -1271,7 +1271,7 @@ function test116_capArgConstructors() {
1271
1271
  // TEST150: JSON roundtrip
1272
1272
  function test150_capManifestJsonSerialization() {
1273
1273
  const capUrn = CapUrn.fromString(test6204_Urn('extract;target=metadata'));
1274
- const cap = new Cap(capUrn, 'Extract Metadata', 'extract-metadata');
1274
+ const cap = new Cap(capUrn, 'Extract Metadata', ['extract-metadata']);
1275
1275
  cap.addArg(new CapArg('media:ext=pdf', true, [new ArgSource({ stdin: 'media:ext=pdf' })]));
1276
1276
  cap.addArg(new CapArg(
1277
1277
  'media:chunk-size;enc=utf-8;numeric',
@@ -1719,7 +1719,7 @@ function test6242_JS_resolveMediaUrnFromSpecs() {
1719
1719
  // TEST6246: J s cap j s o n serialization
1720
1720
  function test6246_JS_capJSONSerialization() {
1721
1721
  const urn = CapUrn.fromString(test6204_Urn('test'));
1722
- const cap = new Cap(urn, 'Test Cap', 'test_command');
1722
+ const cap = new Cap(urn, 'Test Cap', ['test_command']);
1723
1723
  cap.arguments = {
1724
1724
  required: [{ name: 'input', media_urn: MEDIA_STRING }],
1725
1725
  optional: []
@@ -1742,7 +1742,7 @@ function test6246_JS_capJSONSerialization() {
1742
1742
  // surface as failures here.
1743
1743
  function test6249_JS_capDocumentationRoundTrip() {
1744
1744
  const urn = CapUrn.fromString(test6204_Urn('documented'));
1745
- const cap = new Cap(urn, 'Documented Cap', 'documented');
1745
+ const cap = new Cap(urn, 'Documented Cap', ['documented']);
1746
1746
  const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
1747
1747
  cap.setDocumentation(body);
1748
1748
  assertEqual(cap.getDocumentation(), body, 'Setter must store the body verbatim');
@@ -1764,7 +1764,7 @@ function test6249_JS_capDocumentationRoundTrip() {
1764
1764
  // (which has no null sentinel) and pollute generated JSON.
1765
1765
  function test6253_JS_capDocumentationOmittedWhenNull() {
1766
1766
  const urn = CapUrn.fromString(test6204_Urn('undocumented'));
1767
- const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
1767
+ const cap = new Cap(urn, 'Undocumented Cap', ['undocumented']);
1768
1768
  assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
1769
1769
 
1770
1770
  const json = cap.toJSON();
@@ -1854,8 +1854,10 @@ function test6269_JS_mediaDefConstruction() {
1854
1854
  // to assert on. Tests that need an empty channel use literals inline.
1855
1855
  const sampleRegistry = {
1856
1856
  schemaVersion: '5.0',
1857
+ registryVersion: 1,
1857
1858
  lastUpdated: '2026-02-07T16:48:28Z',
1858
1859
  registryUrl: 'https://test.example/manifest',
1860
+ fabricRegistryUrl: 'https://fabric.test',
1859
1861
  channels: {
1860
1862
  release: { cartridges: {
1861
1863
  pdfcartridge: {
@@ -2089,7 +2091,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
2089
2091
  // Missing channels object
2090
2092
  threw = false;
2091
2093
  try {
2092
- new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest'});
2094
+ new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest'});
2093
2095
  } catch (e) {
2094
2096
  threw = true;
2095
2097
  assert(e.message.includes('channels'), 'Should reject missing channels');
@@ -2099,7 +2101,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
2099
2101
  // Missing one of the two required channels
2100
2102
  threw = false;
2101
2103
  try {
2102
- new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
2104
+ new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
2103
2105
  } catch (e) {
2104
2106
  threw = true;
2105
2107
  assert(e.message.includes('nightly'), 'Should require nightly channel');
@@ -2581,7 +2583,9 @@ function test1874_registryUrlFromBuildEnvRejectsEmptyString() {
2581
2583
  function discoveryInstallFixture(root, slug, channelFolder, name, version, cartridgeJson, entry) {
2582
2584
  const fs = require('fs');
2583
2585
  const path = require('path');
2584
- const dir = path.join(root, slug, channelFolder, name, version);
2586
+ // {slug}/v1/{channel}/{name}/{version} — the version level pins to the host
2587
+ // registry version (the tests construct identities at cartridgeRegistryVersion 1).
2588
+ const dir = path.join(root, slug, 'v1', channelFolder, name, version);
2585
2589
  fs.mkdirSync(dir, { recursive: true });
2586
2590
  if (cartridgeJson !== null && cartridgeJson !== undefined) {
2587
2591
  fs.writeFileSync(path.join(dir, 'cartridge.json'), cartridgeJson);
@@ -2634,7 +2638,7 @@ async function test1875_scanAllReachesBothDevAndRegistrySlugs() {
2634
2638
  const url = 'https://cartridges.example.com/manifest';
2635
2639
  const rslug = slugForSync(url);
2636
2640
  // Host baked for a DIFFERENT registry than the on-disk registry cartridge.
2637
- const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1 });
2641
+ const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1, cartridgeRegistryVersion: 1 });
2638
2642
  discoveryInstallFixture(root, 'dev', 'nightly', 'devcart', '1.0.0', discoveryDevCartridgeJson('nightly', 1), 'cart');
2639
2643
  discoveryInstallFixture(root, rslug, 'nightly', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'nightly', 1), 'cart');
2640
2644
  const out = await discoverCartridges(root, host);
@@ -2658,7 +2662,7 @@ async function test1876_otherChannelSubtreeIsSkipped() {
2658
2662
  const url = 'https://cartridges.example.com/manifest';
2659
2663
  const rslug = slugForSync(url);
2660
2664
  discoveryInstallFixture(root, rslug, 'release', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'release', 1), 'cart');
2661
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2665
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2662
2666
  assertEqual(out.length, 0, 'a release-only slug must be invisible to a nightly host');
2663
2667
  } finally {
2664
2668
  discoveryRemoveRoot(root);
@@ -2675,7 +2679,7 @@ async function test1877_registryCartridgeUnderWrongSlugIsBadInstall() {
2675
2679
  const wrongSlug = slugForSync('https://somewhere-else.example.com/manifest');
2676
2680
  const json = discoveryRegistryCartridgeJson(url, 'nightly', 1);
2677
2681
  discoveryInstallFixture(root, wrongSlug, 'nightly', 'cart', '1.0.0', json, 'cart');
2678
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2682
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2679
2683
  discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
2680
2684
  } finally {
2681
2685
  discoveryRemoveRoot(root);
@@ -2703,7 +2707,7 @@ async function test1878_bundledProviderWithoutBakedHashIsRejected() {
2703
2707
  installed_at: '2024-01-01T00:00:00Z', installed_from: 'bundle', fabric_manifest_version: 1,
2704
2708
  });
2705
2709
  discoveryInstallFixture(root, 'dev', 'nightly', 'cart', '1.0.0', json, 'cart');
2706
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2710
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2707
2711
  discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
2708
2712
  assert(out[0].error.message.includes('bundled provider integrity'),
2709
2713
  `message should name the bundled-integrity failure: ${out[0].error.message}`);
@@ -2954,7 +2958,7 @@ function test6544_builderRejectsStructuralKeys() {
2954
2958
  // TEST1294: RULE11 - void-input cap with stdin source rejected
2955
2959
  function test1294_rule11VoidInputWithStdinRejected() {
2956
2960
  const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
2957
- const cap = new Cap(urn, 'Test', 'test-cmd');
2961
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2958
2962
  const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
2959
2963
  cap.args = [new CapArg('media:string', true, [stdinSource])];
2960
2964
  try {
@@ -2969,7 +2973,7 @@ function test1294_rule11VoidInputWithStdinRejected() {
2969
2973
  // TEST1295: RULE11 - non-void-input cap without stdin source rejected
2970
2974
  function test1295_rule11NonVoidInputWithoutStdinRejected() {
2971
2975
  const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
2972
- const cap = new Cap(urn, 'Test', 'test-cmd');
2976
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2973
2977
  const posSource = ArgSource.fromJSON({ cli_flag: '--name' });
2974
2978
  cap.args = [new CapArg('media:string', true, [posSource])];
2975
2979
  try {
@@ -2984,7 +2988,7 @@ function test1295_rule11NonVoidInputWithoutStdinRejected() {
2984
2988
  // TEST1296: RULE11 - void-input cap with only cli_flag sources passes
2985
2989
  function test1296_rule11VoidInputCliFlagOnly() {
2986
2990
  const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
2987
- const cap = new Cap(urn, 'Test', 'test-cmd');
2991
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2988
2992
  const flagSource = ArgSource.fromJSON({ cli_flag: '--name' });
2989
2993
  cap.args = [new CapArg('media:string', true, [flagSource])];
2990
2994
  // Should not throw
@@ -2994,7 +2998,7 @@ function test1296_rule11VoidInputCliFlagOnly() {
2994
2998
  // TEST1297: RULE11 - non-void-input cap with stdin source passes
2995
2999
  function test1297_rule11NonVoidInputWithStdin() {
2996
3000
  const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
2997
- const cap = new Cap(urn, 'Test', 'test-cmd');
3001
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2998
3002
  const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
2999
3003
  cap.args = [new CapArg('media:string', true, [stdinSource])];
3000
3004
  // Should not throw
@@ -3308,14 +3312,25 @@ function test6294_Machine_fanInSecondaryUnassignedGetsWildcard() {
3308
3312
  assertEqual(g.edges()[0].sources[1].toString(), 'media:');
3309
3313
  }
3310
3314
 
3311
- // TEST6306: Machine loop edge
3312
- function test6306_Machine_loopEdge() {
3313
- const g = Machine.fromString(
3314
- '[p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"]' +
3315
- '[pages -> LOOP p2t -> texts]'
3315
+ // TEST6306: The retired LOOP keyword is no longer grammar. A wiring that still
3316
+ // writes `LOOP <cap>` before the cap alias no longer parses — `LOOP` is now an
3317
+ // ordinary alias, so `pages -> LOOP p2t -> texts` is two aliases in the cap
3318
+ // position with no arrow between them, which is a syntax error.
3319
+ function test6306_Machine_loopKeywordIsNotGrammar() {
3320
+ assertThrowsWithCode(
3321
+ () => Machine.fromString(
3322
+ '[p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"]' +
3323
+ '[pages -> LOOP p2t -> texts]'
3324
+ ),
3325
+ MachineSyntaxErrorCodes.PARSE_ERROR
3326
+ );
3327
+
3328
+ // `LOOP` on its own in the cap position parses fine — it is just an alias —
3329
+ // but resolves to nothing, proving it carries no special meaning.
3330
+ assertThrowsWithCode(
3331
+ () => Machine.fromString('[pages -> LOOP -> texts]'),
3332
+ MachineSyntaxErrorCodes.UNDEFINED_ALIAS
3316
3333
  );
3317
- assertEqual(g.edgeCount(), 1);
3318
- assertEqual(g.edges()[0].isLoop, true);
3319
3334
  }
3320
3335
 
3321
3336
  // TEST6308: Machine undefined alias fails
@@ -3416,14 +3431,16 @@ function test6331_Machine_lineBasedTwoStepChain() {
3416
3431
  assertEqual(g.edgeCount(), 2);
3417
3432
  }
3418
3433
 
3419
- // TEST6334: Machine line based loop
3420
- function test6334_Machine_lineBasedLoop() {
3421
- const g = Machine.fromString(
3422
- 'p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"\n' +
3423
- 'pages -> LOOP p2t -> texts'
3434
+ // TEST6334: The retired LOOP keyword is not grammar in line-based mode either —
3435
+ // `pages -> LOOP p2t -> texts` is a syntax error, same as the bracketed form.
3436
+ function test6334_Machine_lineBasedLoopKeywordIsNotGrammar() {
3437
+ assertThrowsWithCode(
3438
+ () => Machine.fromString(
3439
+ 'p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"\n' +
3440
+ 'pages -> LOOP p2t -> texts'
3441
+ ),
3442
+ MachineSyntaxErrorCodes.PARSE_ERROR
3424
3443
  );
3425
- assertEqual(g.edgeCount(), 1);
3426
- assertEqual(g.edges()[0].isLoop, true);
3427
3444
  }
3428
3445
 
3429
3446
  // TEST6337: Machine line based fan in
@@ -3835,8 +3852,12 @@ function test6415_Machine_roundtripFanOut() {
3835
3852
  'Fan-out round-trip failed: ' + notation);
3836
3853
  }
3837
3854
 
3838
- // TEST6417: Machine roundtrip loop edge
3839
- function test6417_Machine_roundtripLoopEdge() {
3855
+ // TEST6417: A per-item map (`is_loop`) edge serializes WITHOUT any LOOP marker —
3856
+ // `is_loop` is a derived cardinality property, not authored notation text. The
3857
+ // pure-JS parse path has no cap definitions to re-derive cardinality, so the
3858
+ // reparsed edge has `isLoop === false`; editors get the derived value from the
3859
+ // engine, not from re-parsing.
3860
+ function test6417_Machine_loopEdgeSerializesWithoutLoopText() {
3840
3861
  const original = new Machine([new MachineEdge(
3841
3862
  [MediaUrn.fromString('media:disbound-page;enc=utf-8')],
3842
3863
  CapUrn.fromString('cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"'),
@@ -3844,9 +3865,17 @@ function test6417_Machine_roundtripLoopEdge() {
3844
3865
  true
3845
3866
  )]);
3846
3867
  const notation = original.toMachineNotation();
3868
+ assert(!notation.includes('LOOP'), 'serialized notation must not contain the retired LOOP keyword');
3869
+
3847
3870
  const reparsed = Machine.fromString(notation);
3848
- assert(original.isEquivalent(reparsed), 'Loop round-trip failed');
3849
- assertEqual(reparsed.edges()[0].isLoop, true, 'isLoop must be preserved');
3871
+ assertEqual(reparsed.edgeCount(), 1);
3872
+ assertEqual(reparsed.edges()[0].isLoop, false,
3873
+ 'pure-JS parse path cannot derive cardinality — isLoop defaults to false');
3874
+ // The structural payload (sources, cap, target) round-trips intact.
3875
+ assert(reparsed.edges()[0].capUrn.isEquivalent(original.edges()[0].capUrn),
3876
+ 'cap URN must round-trip');
3877
+ assert(reparsed.edges()[0].target.isEquivalent(original.edges()[0].target),
3878
+ 'target media must round-trip');
3850
3879
  }
3851
3880
 
3852
3881
  // TEST6419: Machine serialization is deterministic
@@ -4203,16 +4232,21 @@ function test6462_Machine_toMermaid_linearChain() {
4203
4232
  assert(mermaid.includes('(['), 'Should have stadium shape nodes');
4204
4233
  }
4205
4234
 
4206
- // TEST6463: Machine to mermaid loop edge
4235
+ // TEST6463: Mermaid renders a per-item map (`is_loop`) edge with a dotted line
4236
+ // — `is_loop` is a kept render property — but emits NO "LOOP" text, since that
4237
+ // keyword is retired. The loop edge is built programmatically because the
4238
+ // grammar no longer has any way to author one.
4207
4239
  function test6463_Machine_toMermaid_loopEdge() {
4208
- const machine = Machine.fromString(
4209
- '[p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"]' +
4210
- '[pages -> LOOP p2t -> texts]'
4211
- );
4240
+ const machine = new Machine([new MachineEdge(
4241
+ [MediaUrn.fromString('media:disbound-page;enc=utf-8')],
4242
+ CapUrn.fromString('cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"'),
4243
+ MediaUrn.fromString('media:enc=utf-8;ext=txt'),
4244
+ true
4245
+ )]);
4212
4246
  const mermaid = machine.toMermaid();
4213
- assert(mermaid.includes('LOOP'), 'Should include LOOP label');
4214
- assert(mermaid.includes('-.'), 'Should use dotted line for LOOP');
4215
- assert(mermaid.includes('.->'), 'Should use dotted arrow for LOOP');
4247
+ assert(!mermaid.includes('LOOP'), 'Must not emit the retired LOOP label');
4248
+ assert(mermaid.includes('-.'), 'Should use dotted line for the per-item map edge');
4249
+ assert(mermaid.includes('.->'), 'Should use dotted arrow for the per-item map edge');
4216
4250
  }
4217
4251
 
4218
4252
  // TEST6464: Machine to mermaid empty graph
@@ -4307,7 +4341,7 @@ function test6470_Machine_capRegistryEntry_defaults() {
4307
4341
  assertEqual(entry.urn, 'cap:in=media:;test;out=media:', 'URN should match');
4308
4342
  assertEqual(entry.title, '', 'Title should default to empty');
4309
4343
  assertEqual(entry.description, '', 'Description should default to empty');
4310
- assertEqual(entry.command, '', 'Command should default to empty');
4344
+ assert(Array.isArray(entry.aliases) && entry.aliases.length === 0, 'Aliases should default to an empty array');
4311
4345
  assert(Array.isArray(entry.args), 'Args should default to array');
4312
4346
  assertEqual(entry.args.length, 0, 'Args should be empty');
4313
4347
  }
@@ -6307,7 +6341,7 @@ function test6736_axisWeightingDecodedLayout() {
6307
6341
  // TEST6737: Cap with version=0 round-trips with no `version` key on wire
6308
6342
  function test6737_capVersionZeroOmittedOnWire() {
6309
6343
  const urn = CapUrn.fromString('cap:in="media:void";test-op;out="media:enc=utf-8;record"');
6310
- const cap = new Cap(urn, 'Test Cap', 'test-op');
6344
+ const cap = new Cap(urn, 'Test Cap', ['test-op']);
6311
6345
  // version defaults to 0
6312
6346
  assertEqual(cap.version, 0, 'Default version should be 0');
6313
6347
  const json = cap.toJSON();
@@ -6319,7 +6353,7 @@ function test6737_capVersionZeroOmittedOnWire() {
6319
6353
  // TEST1848: Cap with version=N round-trips with `version: N` on wire
6320
6354
  function test1848_capVersionNonZeroOnWire() {
6321
6355
  const urn = CapUrn.fromString('cap:in="media:void";versioned-op;out="media:enc=utf-8;record"');
6322
- const cap = new Cap(urn, 'Versioned Cap', 'versioned-op');
6356
+ const cap = new Cap(urn, 'Versioned Cap', ['versioned-op']);
6323
6357
  cap.version = 42;
6324
6358
  const json = cap.toJSON();
6325
6359
  assert('version' in json, 'version!=0 must appear on wire');
@@ -6489,6 +6523,167 @@ function test1196_aliasedSerializationUsesAliasAndDropsHeader() {
6489
6523
  // Test runner
6490
6524
  // ============================================================================
6491
6525
 
6526
+
6527
+ // ============================================================================
6528
+ // Unified planner plan-space (planner.js) — TEST1450+
6529
+ // ============================================================================
6530
+
6531
+ const plannerNS = require('./planner.js');
6532
+
6533
+ /** Planner-specific throw assertion: any PlanStateError counts. */
6534
+ function assertThrowsPlanState(fn, message) {
6535
+ try {
6536
+ fn();
6537
+ } catch (error) {
6538
+ if (error instanceof plannerNS.PlanStateError) return;
6539
+ throw new Error(`Expected PlanStateError but got ${error.name}: ${error.message} (${message})`);
6540
+ }
6541
+ throw new Error(`Expected PlanStateError but function succeeded: ${message}`);
6542
+ }
6543
+
6544
+ // TEST1450: a defaults-only request is AUTO with every knob at its default,
6545
+ // and the proto JSON carries the documented wire shape.
6546
+ function test1450_planRequestDefaults() {
6547
+ const request = new plannerNS.PlanRequest({
6548
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6549
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6550
+ });
6551
+ assertEqual(request.isConfigured, false, 'defaults are not configured');
6552
+ const wire = request.toProtoJSON();
6553
+ assertEqual(wire.mode, 0, 'AUTO mode');
6554
+ assertEqual(wire.ranking, 0, 'intent ranking');
6555
+ assertEqual(wire.convergence.presence, 0, 'auto presence');
6556
+ assertEqual(wire.convergence.mechanism, 0, 'any mechanism');
6557
+ assertEqual(wire.sources[0].media_urn, 'media:ext=pdf', 'snake_case source field');
6558
+ assertEqual(wire.sources[0].is_sequence, false, 'snake_case sequence field');
6559
+ assertEqual(wire.max_depth, 0, '0 = engine default depth');
6560
+ }
6561
+
6562
+ // TEST1451: at-depth REQUIRES a positive depth; a depth anywhere else is
6563
+ // invalid; ranking never flips the request to CONFIGURED but any
6564
+ // space-constraining knob does (mode is forced to CONFIGURED on the wire).
6565
+ function test1451_planRequestKnobValidation() {
6566
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6567
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6568
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6569
+ convergenceLocation: 'at-depth',
6570
+ }), 'at-depth without a depth must throw');
6571
+
6572
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6573
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6574
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6575
+ convergenceAtDepth: 2,
6576
+ }), 'a depth without at-depth must throw');
6577
+
6578
+ const ranked = new plannerNS.PlanRequest({
6579
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6580
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6581
+ ranking: 'shortest',
6582
+ });
6583
+ assertEqual(ranked.isConfigured, false, 'ranking alone never configures');
6584
+ assertEqual(ranked.toProtoJSON().mode, 0, 'ranking alone stays AUTO');
6585
+
6586
+ const constrained = new plannerNS.PlanRequest({
6587
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6588
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6589
+ convergenceMechanism: 'collect',
6590
+ });
6591
+ assertEqual(constrained.isConfigured, true, 'a mechanism constrains');
6592
+ assertEqual(constrained.toProtoJSON().mode, 1, 'constrained ⇒ CONFIGURED on the wire');
6593
+
6594
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6595
+ sources: [],
6596
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6597
+ }), 'no sources must throw');
6598
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6599
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6600
+ exactTargets: [],
6601
+ }), 'no targets must throw');
6602
+ }
6603
+
6604
+ // TEST1452: every knob round-trips value → proto number → value, and an
6605
+ // unknown value/number fails hard in both directions.
6606
+ function test1452_knobProtoRoundTrip() {
6607
+ const knobs = [
6608
+ plannerNS.ConvergencePresence, plannerNS.ConvergenceLocation,
6609
+ plannerNS.ConvergenceMechanism, plannerNS.ConvergenceArity,
6610
+ plannerNS.DivergencePresence, plannerNS.DivergenceLocation,
6611
+ plannerNS.RankPolicy, plannerNS.PlanMode,
6612
+ ];
6613
+ for (const knob of knobs) {
6614
+ for (const value of knob.values) {
6615
+ assertEqual(knob.fromProto(knob.toProto(value)), value, `${knob.name} round-trips ${value}`);
6616
+ }
6617
+ assertThrowsPlanState(() => knob.toProto('no-such-value'), `${knob.name} rejects unknown value`);
6618
+ assertThrowsPlanState(() => knob.fromProto(999), `${knob.name} rejects unknown number`);
6619
+ }
6620
+ }
6621
+
6622
+ // TEST1453: candidate/target parsers accept realistic proto-JSON and sort
6623
+ // candidates by rank; malformed payloads (missing field, wrong type) fail
6624
+ // hard with the offending path in the message.
6625
+ function test1453_planResponseParsers() {
6626
+ const candidates = plannerNS.parsePlanCandidates({
6627
+ candidates: [
6628
+ {
6629
+ notation: '[n0 -> edge_1 -> n1]',
6630
+ label: 'Convert each',
6631
+ rank: 1,
6632
+ profile: { source_media: ['media:ext=pdf'], target_media: ['media:enc=utf-8;ext=txt'], apexes: [], converged: false, diverged: false },
6633
+ cost: { cap_steps: 2, total_steps: 2, max_leg_depth: 0, intent_score: 0.5 },
6634
+ },
6635
+ {
6636
+ notation: '[(n0, n1) -> edge_0 -> n2]',
6637
+ label: 'Combine via text',
6638
+ rank: 0,
6639
+ profile: {
6640
+ source_media: ['media:ext=pdf', 'media:ext=md'],
6641
+ target_media: ['media:enc=utf-8;ext=txt'],
6642
+ apexes: [{ media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 }],
6643
+ converged: true,
6644
+ diverged: false,
6645
+ },
6646
+ cost: { cap_steps: 3, total_steps: 4, max_leg_depth: 1, intent_score: 0.8 },
6647
+ },
6648
+ ],
6649
+ });
6650
+ assertEqual(candidates.length, 2, 'two candidates');
6651
+ assertEqual(candidates[0].rank, 0, 'sorted by rank');
6652
+ assertEqual(candidates[0].profile.apexes[0].mechanism, 'collect', 'proto mechanism decoded to knob value');
6653
+ assertEqual(candidates[0].profile.converged, true, 'converged carried');
6654
+
6655
+ assertThrowsPlanState(
6656
+ () => plannerNS.parsePlanCandidates({ candidates: [{ label: 'missing notation', rank: 0 }] }),
6657
+ 'a candidate without notation must fail hard'
6658
+ );
6659
+
6660
+ const targets = plannerNS.parseConvergentTargets({
6661
+ targets: [
6662
+ {
6663
+ media_def: 'media:enc=utf-8;ext=txt',
6664
+ display_name: 'Plain Text',
6665
+ min_total_steps: 3,
6666
+ apex: { media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 },
6667
+ convergent: true,
6668
+ file_extension: 'txt',
6669
+ },
6670
+ {
6671
+ media_def: 'media:ext=png;image',
6672
+ display_name: 'PNG Image',
6673
+ min_total_steps: 2,
6674
+ convergent: false,
6675
+ file_extension: 'png',
6676
+ },
6677
+ ],
6678
+ });
6679
+ assertEqual(targets[0].apex.mediaUrn, 'media:enc=utf-8;page', 'apex carried');
6680
+ assertEqual(targets[1].apex, null, 'independent target has no apex');
6681
+ assertThrowsPlanState(
6682
+ () => plannerNS.parseConvergentTargets({ targets: [{ display_name: 'no media_def' }] }),
6683
+ 'a target without media_def must fail hard'
6684
+ );
6685
+ }
6686
+
6492
6687
  async function runTests() {
6493
6688
  console.log('Running capdag-js tests...\n');
6494
6689
 
@@ -6749,7 +6944,7 @@ async function runTests() {
6749
6944
  runTest('MACHINE:fan_out', test6290_Machine_fanOut);
6750
6945
  runTest('MACHINE:fan_in_secondary_assigned_by_prior_wiring', test6292_Machine_fanInSecondaryAssignedByPriorWiring);
6751
6946
  runTest('MACHINE:fan_in_secondary_unassigned_gets_wildcard', test6294_Machine_fanInSecondaryUnassignedGetsWildcard);
6752
- runTest('MACHINE:loop_edge', test6306_Machine_loopEdge);
6947
+ runTest('MACHINE:loop_keyword_is_not_grammar', test6306_Machine_loopKeywordIsNotGrammar);
6753
6948
  runTest('MACHINE:undefined_alias_fails', test6308_Machine_undefinedAliasFails);
6754
6949
  runTest('MACHINE:node_alias_collision', test6310_Machine_nodeAliasCollision);
6755
6950
  runTest('MACHINE:conflicting_media_types_fail', test6312_Machine_conflictingMediaTypesFail);
@@ -6762,7 +6957,7 @@ async function runTests() {
6762
6957
  console.log('\n--- machine/parser.rs (line-based) ---');
6763
6958
  runTest('MACHINE:line_based_simple_chain', test6327_Machine_lineBasedSimpleChain);
6764
6959
  runTest('MACHINE:line_based_two_step_chain', test6331_Machine_lineBasedTwoStepChain);
6765
- runTest('MACHINE:line_based_loop', test6334_Machine_lineBasedLoop);
6960
+ runTest('MACHINE:line_based_loop_keyword_is_not_grammar', test6334_Machine_lineBasedLoopKeywordIsNotGrammar);
6766
6961
  runTest('MACHINE:line_based_fan_in', test6337_Machine_lineBasedFanIn);
6767
6962
  runTest('MACHINE:mixed_bracketed_and_line_based', test6341_Machine_mixedBracketedAndLineBased);
6768
6963
  runTest('MACHINE:line_based_equivalent_to_bracketed', test6345_Machine_lineBasedEquivalentToBracketed);
@@ -6797,7 +6992,7 @@ async function runTests() {
6797
6992
  runTest('MACHINE:roundtrip_single_edge', test6410_Machine_roundtripSingleEdge);
6798
6993
  runTest('MACHINE:roundtrip_two_edge_chain', test6413_Machine_roundtripTwoEdgeChain);
6799
6994
  runTest('MACHINE:roundtrip_fan_out', test6415_Machine_roundtripFanOut);
6800
- runTest('MACHINE:roundtrip_loop_edge', test6417_Machine_roundtripLoopEdge);
6995
+ runTest('MACHINE:loop_edge_serializes_without_loop_text', test6417_Machine_loopEdgeSerializesWithoutLoopText);
6801
6996
  runTest('MACHINE:serialization_is_deterministic', test6419_Machine_serializationIsDeterministic);
6802
6997
  runTest('MACHINE:reordered_edges_produce_same_notation', test6421_Machine_reorderedEdgesProduceSameNotation);
6803
6998
  runTest('MACHINE:multiline_serialize_format', test6429_Machine_multilineSerializeFormat);
@@ -6957,6 +7152,13 @@ async function runTests() {
6957
7152
  runTest('TEST1896: cached_cap_aliases_filters_to_cap', test1896_cachedCapAliasesFiltersToCapTargets);
6958
7153
  runTest('TEST1196: aliased_serialization_uses_alias', test1196_aliasedSerializationUsesAliasAndDropsHeader);
6959
7154
 
7155
+ // planner.js: unified plan-space vocabulary
7156
+ console.log('--- planner.js ---');
7157
+ runTest('TEST1450: plan_request_defaults', test1450_planRequestDefaults);
7158
+ runTest('TEST1451: plan_request_knob_validation', test1451_planRequestKnobValidation);
7159
+ runTest('TEST1452: knob_proto_round_trip', test1452_knobProtoRoundTrip);
7160
+ runTest('TEST1453: plan_response_parsers', test1453_planResponseParsers);
7161
+
6960
7162
  // Summary
6961
7163
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
6962
7164
  if (failCount > 0) {