capdag 1.226.626 → 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.
@@ -1125,7 +1125,7 @@ function buildBrowseGraphData(capabilities) {
1125
1125
  // Parsing the URN canonicalizes it and validates it — fail hard on
1126
1126
  // malformed registry data.
1127
1127
  const parsedUrn = CapUrn.fromString(capData.urn);
1128
- const cap = createCap(parsedUrn, title, capData.command || '');
1128
+ const cap = createCap(parsedUrn, title, capData.aliases || []);
1129
1129
  const capFabEdgeIndex = capFab.edges.length;
1130
1130
  capFab.addCap(cap, 'registry');
1131
1131
 
package/capdag.js CHANGED
@@ -2530,21 +2530,22 @@ class Cap {
2530
2530
  * @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
2531
2531
  * @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.
2532
2532
  */
2533
- constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
2533
+ constructor(urn, title, aliases, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
2534
2534
  if (!(urn instanceof CapUrn)) {
2535
2535
  throw new Error('URN must be a CapUrn instance');
2536
2536
  }
2537
2537
  if (!title || typeof title !== 'string') {
2538
2538
  throw new Error('Title is required and must be a string');
2539
2539
  }
2540
- if (!command || typeof command !== 'string') {
2541
- throw new Error('Command is required and must be a string');
2540
+ if (!Array.isArray(aliases) || aliases.length === 0) {
2541
+ throw new Error('Aliases are required and must be a non-empty array');
2542
2542
  }
2543
2543
 
2544
2544
  this.urn = urn;
2545
2545
  this.version = 0;
2546
2546
  this.title = title;
2547
- this.command = command;
2547
+ this.aliases = aliases;
2548
+ this.is_abstract = false;
2548
2549
  this.cap_description = capDescription;
2549
2550
  this.documentation = documentation;
2550
2551
  this.metadata = metadata || {};
@@ -2752,7 +2753,8 @@ class Cap {
2752
2753
 
2753
2754
  return this.urn.equals(other.urn) &&
2754
2755
  this.title === other.title &&
2755
- this.command === other.command &&
2756
+ JSON.stringify([...this.aliases].sort()) === JSON.stringify([...other.aliases].sort()) &&
2757
+ this.is_abstract === other.is_abstract &&
2756
2758
  this.cap_description === other.cap_description &&
2757
2759
  this.documentation === other.documentation &&
2758
2760
  JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
@@ -2772,7 +2774,7 @@ class Cap {
2772
2774
  const result = {
2773
2775
  urn: this.urn.toString(),
2774
2776
  title: this.title,
2775
- command: this.command,
2777
+ aliases: this.aliases,
2776
2778
  cap_description: this.cap_description,
2777
2779
  metadata: this.metadata,
2778
2780
  args: this.args.map(a => a.toJSON()),
@@ -2783,6 +2785,10 @@ class Cap {
2783
2785
  result.version = this.version;
2784
2786
  }
2785
2787
 
2788
+ if (this.is_abstract) {
2789
+ result.abstract = true;
2790
+ }
2791
+
2786
2792
  // Long-form markdown documentation. Only emitted when set, to match
2787
2793
  // the Rust serializer which skips this field when None.
2788
2794
  if (typeof this.documentation === 'string' && this.documentation.length > 0) {
@@ -2821,7 +2827,11 @@ class Cap {
2821
2827
  const documentation = (typeof json.documentation === 'string' && json.documentation.length > 0)
2822
2828
  ? json.documentation
2823
2829
  : null;
2824
- const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json, documentation);
2830
+ if (!Array.isArray(json.aliases) || json.aliases.length === 0) {
2831
+ throw new Error(`cap '${json.urn}' must declare at least one alias (the 'aliases' field is required and non-empty)`);
2832
+ }
2833
+ const cap = new Cap(urn, json.title, json.aliases, json.cap_description, json.metadata, json.metadata_json, documentation);
2834
+ cap.is_abstract = json.abstract === true;
2825
2835
  cap.version = (typeof json.version === 'number' && json.version !== 0) ? json.version : 0;
2826
2836
  // Parse args (new format)
2827
2837
  if (json.args && Array.isArray(json.args)) {
@@ -3046,20 +3056,20 @@ class CapManifest {
3046
3056
  /**
3047
3057
  * Helper functions for creating capabilities
3048
3058
  */
3049
- function createCap(urn, title, command) {
3050
- return new Cap(urn, title, command);
3059
+ function createCap(urn, title, aliases) {
3060
+ return new Cap(urn, title, aliases);
3051
3061
  }
3052
3062
 
3053
- function createCapWithDescription(urn, title, command, description) {
3054
- return new Cap(urn, title, command, description);
3063
+ function createCapWithDescription(urn, title, aliases, description) {
3064
+ return new Cap(urn, title, aliases, description);
3055
3065
  }
3056
3066
 
3057
- function createCapWithMetadata(urn, title, command, metadata) {
3058
- return new Cap(urn, title, command, null, metadata);
3067
+ function createCapWithMetadata(urn, title, aliases, metadata) {
3068
+ return new Cap(urn, title, aliases, null, metadata);
3059
3069
  }
3060
3070
 
3061
- function createCapWithDescriptionAndMetadata(urn, title, command, description, metadata) {
3062
- return new Cap(urn, title, command, description, metadata);
3071
+ function createCapWithDescriptionAndMetadata(urn, title, aliases, description, metadata) {
3072
+ return new Cap(urn, title, aliases, description, metadata);
3063
3073
  }
3064
3074
 
3065
3075
  // ============================================================================
@@ -3747,9 +3757,9 @@ class CapValidator {
3747
3757
  });
3748
3758
  }
3749
3759
 
3750
- if (!cap.command || typeof cap.command !== 'string') {
3760
+ if (!Array.isArray(cap.aliases) || cap.aliases.length === 0) {
3751
3761
  throw new ValidationError('InvalidCapSchema', capUrn, {
3752
- issue: 'Cap must have a valid command'
3762
+ issue: 'Cap must have at least one alias'
3753
3763
  });
3754
3764
  }
3755
3765
 
@@ -4355,37 +4365,55 @@ class CartridgeCapGroup {
4355
4365
  // in synchronous contexts must await.
4356
4366
 
4357
4367
  const DEV_SLUG = "dev";
4358
- const SLUG_HEX_LEN = 16;
4359
4368
 
4360
4369
  /**
4361
- * Compute the on-disk slug for a registry URL.
4370
+ * The authority (host[:port]) of a registry URL: after `://` up to the next
4371
+ * `/`, `?`, or `#` (path/query/fragment discarded).
4372
+ */
4373
+ function authorityOfRegistryUrl(url) {
4374
+ const i = url.indexOf("://");
4375
+ const afterScheme = i >= 0 ? url.slice(i + 3) : url;
4376
+ const m = afterScheme.search(/[/?#]/);
4377
+ return m >= 0 ? afterScheme.slice(0, m) : afterScheme;
4378
+ }
4379
+
4380
+ /**
4381
+ * Compute the on-disk slug for a registry URL. Mirrors
4382
+ * capdag::cartridge_slug::slug_for byte-for-byte: `DEV_SLUG` for null/undefined,
4383
+ * otherwise a path-safe transform of the URL's authority (host[:port]) —
4384
+ * ASCII-lowercased, every character outside `[a-z0-9.-]` replaced by `-`.
4385
+ * Depends ONLY on the authority; path/version/query/trailing-slash/host-case
4386
+ * do not change it.
4362
4387
  *
4363
- * @param {string|null|undefined} registryUrl - The registry URL, or
4364
- * null/undefined for dev installs.
4365
- * @returns {Promise<string>} The slug — `DEV_SLUG` for null,
4366
- * otherwise the first 16 lowercase-hex characters of
4367
- * sha256(registryUrl).
4388
+ * @param {string|null|undefined} registryUrl
4389
+ * @returns {string}
4368
4390
  */
4369
- async function slugForRegistryUrl(registryUrl) {
4391
+ function slugForRegistryUrlSync(registryUrl) {
4370
4392
  if (registryUrl === null || registryUrl === undefined) {
4371
4393
  return DEV_SLUG;
4372
4394
  }
4373
- const bytes = new TextEncoder().encode(registryUrl);
4374
- // Web Crypto exposes SHA-256 via crypto.subtle. Node 16+ exposes
4375
- // it through globalThis.crypto.subtle.
4376
- const digestBuffer = await crypto.subtle.digest("SHA-256", bytes);
4377
- const digestBytes = new Uint8Array(digestBuffer);
4378
- let hex = "";
4379
- for (const b of digestBytes) {
4380
- hex += b.toString(16).padStart(2, "0");
4395
+ let out = "";
4396
+ for (const ch of authorityOfRegistryUrl(registryUrl)) {
4397
+ const lc = ch >= "A" && ch <= "Z" ? ch.toLowerCase() : ch;
4398
+ out += (lc >= "a" && lc <= "z") || (lc >= "0" && lc <= "9") || lc === "." || lc === "-" ? lc : "-";
4381
4399
  }
4382
- return hex.slice(0, SLUG_HEX_LEN);
4400
+ return out;
4401
+ }
4402
+
4403
+ /**
4404
+ * Async wrapper retained for callers that `await` the slug. The computation is
4405
+ * synchronous (no hashing) — the authority transform needs no crypto.
4406
+ * @returns {Promise<string>}
4407
+ */
4408
+ async function slugForRegistryUrl(registryUrl) {
4409
+ return slugForRegistryUrlSync(registryUrl);
4383
4410
  }
4384
4411
 
4385
4412
  function isRegistrySlug(s) {
4386
4413
  return typeof s === "string"
4387
- && s.length === SLUG_HEX_LEN
4388
- && /^[0-9a-f]+$/.test(s);
4414
+ && s.length > 0
4415
+ && s !== DEV_SLUG
4416
+ && /^[a-z0-9.-]+$/.test(s);
4389
4417
  }
4390
4418
 
4391
4419
  // =============================================================================
@@ -4744,6 +4772,18 @@ class CartridgeRepoClient {
4744
4772
  if (data.schemaVersion !== '5.0') {
4745
4773
  throw new Error(`Cartridge registry from ${repoUrl} has schemaVersion '${data.schemaVersion}'; required: 5.0`);
4746
4774
  }
4775
+ // Cartridge registry regime version. v0 was the pre-versioning legacy at
4776
+ // the bare /manifest path; this speaks only v1 at /v1/manifest. A manifest
4777
+ // from a different regime version is rejected, not reinterpreted.
4778
+ if (data.registryVersion !== 1) {
4779
+ throw new Error(`Cartridge registry from ${repoUrl} has registryVersion '${data.registryVersion}'; this build speaks v1`);
4780
+ }
4781
+ // A cartridge registry must declare the fabric its caps resolve against. The
4782
+ // client cross-checks this equals its own fabric (so every registry it uses
4783
+ // shares one fabric); here we require it is at least present.
4784
+ if (typeof data.fabricRegistryUrl !== 'string' || data.fabricRegistryUrl.length === 0) {
4785
+ throw new Error(`Cartridge registry from ${repoUrl}: missing required 'fabricRegistryUrl' (the fabric its caps resolve against)`);
4786
+ }
4747
4787
  // Self-referential check: the manifest declares its own URL via
4748
4788
  // `registryUrl`. It must match the URL we just fetched from
4749
4789
  // byte-for-byte — a mismatch is a manifest-corruption signal
@@ -5021,6 +5061,12 @@ class CartridgeRepoServer {
5021
5061
  if (this.registry.schemaVersion !== '5.0') {
5022
5062
  throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 5.0`);
5023
5063
  }
5064
+ if (this.registry.registryVersion !== 1) {
5065
+ throw new Error(`Unsupported cartridge registry version: ${this.registry.registryVersion}. This build speaks v1.`);
5066
+ }
5067
+ if (typeof this.registry.fabricRegistryUrl !== 'string' || this.registry.fabricRegistryUrl.length === 0) {
5068
+ throw new Error('Registry must declare a non-empty `fabricRegistryUrl` (the fabric its caps resolve against)');
5069
+ }
5024
5070
  if (typeof this.registry.registryUrl !== 'string' || this.registry.registryUrl.length === 0) {
5025
5071
  throw new Error('Registry must have a non-empty top-level `registryUrl` field (self-referential URL)');
5026
5072
  }
@@ -5450,24 +5496,15 @@ class InstalledCartridgeRecord {
5450
5496
  /**
5451
5497
  * Synchronous on-disk slug for a registry URL. Mirrors
5452
5498
  * capdag::bifaci::cartridge_slug::slug_for byte-for-byte: `null`/`undefined`
5453
- * (a dev cartridge) → the literal `DEV_SLUG`; otherwise the first
5454
- * SLUG_HEX_LEN lowercase-hex characters of sha256(url bytes).
5455
- *
5456
- * The async `slugForRegistryUrl` above uses Web Crypto for browser parity;
5457
- * discovery runs only under Node and needs a synchronous slug in tight
5458
- * per-file loops, so this uses Node's synchronous `crypto.createHash`. Both
5459
- * produce identical output.
5499
+ * (a dev cartridge) → the literal `DEV_SLUG`; otherwise a path-safe transform
5500
+ * of the URL's authority (host[:port]). Identical output to the async
5501
+ * `slugForRegistryUrl` above — no hashing, so both are trivially in sync.
5460
5502
  *
5461
5503
  * @param {string|null|undefined} registryUrl
5462
5504
  * @returns {string}
5463
5505
  */
5464
5506
  function slugForSync(registryUrl) {
5465
- if (registryUrl === null || registryUrl === undefined) {
5466
- return DEV_SLUG;
5467
- }
5468
- const crypto = require('crypto');
5469
- const hex = crypto.createHash('sha256').update(registryUrl, 'utf8').digest('hex');
5470
- return hex.slice(0, SLUG_HEX_LEN);
5507
+ return slugForRegistryUrlSync(registryUrl);
5471
5508
  }
5472
5509
 
5473
5510
  /**
@@ -5838,14 +5875,21 @@ class DiscoveryIdentity {
5838
5875
  * @param {string|null} opts.registryUrl - Some(url) for release/nightly
5839
5876
  * hosts, null for dev hosts.
5840
5877
  * @param {number} opts.fabricManifestVersion
5878
+ * @param {number} opts.cartridgeRegistryVersion - The registry regime version
5879
+ * this host speaks; an on-disk PATH level ({slug}/v{N}/{channel}/…), pinned
5880
+ * like the channel so a v1 host never scans a v2 tree.
5841
5881
  */
5842
- constructor({ channel, registryUrl, fabricManifestVersion }) {
5882
+ constructor({ channel, registryUrl, fabricManifestVersion, cartridgeRegistryVersion }) {
5843
5883
  if (channel !== CartridgeChannel.Release && channel !== CartridgeChannel.Nightly) {
5844
5884
  throw new Error(`DiscoveryIdentity: invalid channel '${channel}'`);
5845
5885
  }
5886
+ if (!Number.isInteger(cartridgeRegistryVersion) || cartridgeRegistryVersion < 1) {
5887
+ throw new Error(`DiscoveryIdentity: cartridgeRegistryVersion must be an integer >= 1, got '${cartridgeRegistryVersion}'`);
5888
+ }
5846
5889
  this.channel = channel;
5847
5890
  this.registryUrl = registryUrl !== undefined ? registryUrl : null;
5848
5891
  this.fabricManifestVersion = fabricManifestVersion;
5892
+ this.cartridgeRegistryVersion = cartridgeRegistryVersion;
5849
5893
  }
5850
5894
 
5851
5895
  /** This host's own baked-registry slug (`dev` when registryUrl is null). */
@@ -6175,7 +6219,9 @@ async function discoverCartridges(cartridgesRoot, identity) {
6175
6219
  continue;
6176
6220
  }
6177
6221
  const expectedSlug = slugEntry.name;
6178
- const scanRoot = path.join(slugDir, identity.channel);
6222
+ // {slug}/v{cartridgeRegistryVersion}/{channel}/… — the registry regime
6223
+ // version is a path level pinned to the host's version (like the channel).
6224
+ const scanRoot = path.join(slugDir, `v${identity.cartridgeRegistryVersion}`, identity.channel);
6179
6225
  let scanStat;
6180
6226
  try { scanStat = fs.statSync(scanRoot); } catch (_e) { continue; }
6181
6227
  if (!scanStat.isDirectory()) continue;
@@ -7425,7 +7471,8 @@ class FabricRegistryEntry {
7425
7471
  constructor(data) {
7426
7472
  this.urn = data.urn;
7427
7473
  this.title = data.title || '';
7428
- this.command = data.command || '';
7474
+ this.aliases = data.aliases || [];
7475
+ this.isAbstract = data.abstract === true;
7429
7476
  this.description = data.cap_description || '';
7430
7477
  this.args = data.args || [];
7431
7478
  this.output = data.output || null;
@@ -7880,8 +7927,30 @@ class Manifest {
7880
7927
  }
7881
7928
  }
7882
7929
 
7930
+ // Unified configurable planner vocabulary (mirrors Rust plan_space.rs and the
7931
+ // PlanMachines/DiscoverConvergentTargets proto surface).
7932
+ const planner = require('./planner.js');
7933
+
7883
7934
  // Export for CommonJS
7884
7935
  module.exports = {
7936
+ // Planner plan-space vocabulary
7937
+ PlanStateError: planner.PlanStateError,
7938
+ ConvergencePresence: planner.ConvergencePresence,
7939
+ ConvergenceLocation: planner.ConvergenceLocation,
7940
+ ConvergenceMechanism: planner.ConvergenceMechanism,
7941
+ ConvergenceArity: planner.ConvergenceArity,
7942
+ DivergencePresence: planner.DivergencePresence,
7943
+ DivergenceLocation: planner.DivergenceLocation,
7944
+ RankPolicy: planner.RankPolicy,
7945
+ PlanMode: planner.PlanMode,
7946
+ PlanRequest: planner.PlanRequest,
7947
+ parsePlanApex: planner.parsePlanApex,
7948
+ parsePlanProfile: planner.parsePlanProfile,
7949
+ parsePlanCost: planner.parsePlanCost,
7950
+ parsePlanCandidate: planner.parsePlanCandidate,
7951
+ parsePlanCandidates: planner.parsePlanCandidates,
7952
+ parseConvergentTarget: planner.parseConvergentTarget,
7953
+ parseConvergentTargets: planner.parseConvergentTargets,
7885
7954
  ALIAS_TARGET_CAP,
7886
7955
  ALIAS_TARGET_MEDIA,
7887
7956
  tokenIsUrn,
@@ -8087,7 +8156,7 @@ module.exports = {
8087
8156
  discoverCartridges,
8088
8157
  // Registry slug
8089
8158
  DEV_SLUG,
8090
- SLUG_HEX_LEN,
8159
+ slugForRegistryUrlSync,
8091
8160
  slugForRegistryUrl,
8092
8161
  isRegistrySlug,
8093
8162
  // Machine notation
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
@@ -4337,7 +4341,7 @@ function test6470_Machine_capRegistryEntry_defaults() {
4337
4341
  assertEqual(entry.urn, 'cap:in=media:;test;out=media:', 'URN should match');
4338
4342
  assertEqual(entry.title, '', 'Title should default to empty');
4339
4343
  assertEqual(entry.description, '', 'Description should default to empty');
4340
- 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');
4341
4345
  assert(Array.isArray(entry.args), 'Args should default to array');
4342
4346
  assertEqual(entry.args.length, 0, 'Args should be empty');
4343
4347
  }
@@ -6337,7 +6341,7 @@ function test6736_axisWeightingDecodedLayout() {
6337
6341
  // TEST6737: Cap with version=0 round-trips with no `version` key on wire
6338
6342
  function test6737_capVersionZeroOmittedOnWire() {
6339
6343
  const urn = CapUrn.fromString('cap:in="media:void";test-op;out="media:enc=utf-8;record"');
6340
- const cap = new Cap(urn, 'Test Cap', 'test-op');
6344
+ const cap = new Cap(urn, 'Test Cap', ['test-op']);
6341
6345
  // version defaults to 0
6342
6346
  assertEqual(cap.version, 0, 'Default version should be 0');
6343
6347
  const json = cap.toJSON();
@@ -6349,7 +6353,7 @@ function test6737_capVersionZeroOmittedOnWire() {
6349
6353
  // TEST1848: Cap with version=N round-trips with `version: N` on wire
6350
6354
  function test1848_capVersionNonZeroOnWire() {
6351
6355
  const urn = CapUrn.fromString('cap:in="media:void";versioned-op;out="media:enc=utf-8;record"');
6352
- const cap = new Cap(urn, 'Versioned Cap', 'versioned-op');
6356
+ const cap = new Cap(urn, 'Versioned Cap', ['versioned-op']);
6353
6357
  cap.version = 42;
6354
6358
  const json = cap.toJSON();
6355
6359
  assert('version' in json, 'version!=0 must appear on wire');
@@ -6519,6 +6523,167 @@ function test1196_aliasedSerializationUsesAliasAndDropsHeader() {
6519
6523
  // Test runner
6520
6524
  // ============================================================================
6521
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
+
6522
6687
  async function runTests() {
6523
6688
  console.log('Running capdag-js tests...\n');
6524
6689
 
@@ -6987,6 +7152,13 @@ async function runTests() {
6987
7152
  runTest('TEST1896: cached_cap_aliases_filters_to_cap', test1896_cachedCapAliasesFiltersToCapTargets);
6988
7153
  runTest('TEST1196: aliased_serialization_uses_alias', test1196_aliasedSerializationUsesAliasAndDropsHeader);
6989
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
+
6990
7162
  // Summary
6991
7163
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
6992
7164
  if (failCount > 0) {
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  "pretest": "npm run build:parser",
41
41
  "test": "node capdag.test.js"
42
42
  },
43
- "version": "1.226.626"
43
+ "version": "1.233.649"
44
44
  }