capdag 0.127.280 → 0.138.305

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.
Files changed (3) hide show
  1. package/capdag.js +147 -90
  2. package/capdag.test.js +359 -270
  3. package/package.json +2 -2
package/capdag.js CHANGED
@@ -27,47 +27,102 @@ const ErrorCodes = {
27
27
  UNTERMINATED_QUOTE: 8,
28
28
  INVALID_ESCAPE_SEQUENCE: 9,
29
29
  MISSING_IN_SPEC: 10,
30
- MISSING_OUT_SPEC: 11
30
+ MISSING_OUT_SPEC: 11,
31
+ EMPTY_VALUE: 12,
32
+ INVALID_IN_SPEC: 13,
33
+ INVALID_OUT_SPEC: 14
31
34
  };
32
35
 
33
36
  // Note: All parsing is delegated to TaggedUrn from tagged-urn-js
34
37
  // No duplicate state machine or parsing helpers needed here
35
38
 
36
39
  /**
37
- * Cap URN implementation with required direction (in/out) and optional tags
40
+ * Cap URN implementation with direction specs and optional tags.
38
41
  *
39
- * Direction is now a REQUIRED first-class field:
40
- * - inSpec: The input media URN (required, must start with "media:")
41
- * - outSpec: The output media URN (required, must start with "media:")
42
+ * Direction rules match the Rust reference:
43
+ * - missing `in` or `out` defaults to `media:`
44
+ * - `in=*` and `out=*` normalize to `media:`
45
+ * - empty `in=` / `out=` are rejected
42
46
  * - tags: Other optional tags (no longer contains in/out)
43
47
  */
44
48
  /**
45
- * Check if a value is a valid media URN or wildcard
46
- * @param {string} value - The value to check
47
- * @returns {boolean} True if valid media URN or wildcard
49
+ * Normalize a parsed direction tag to canonical wildcard semantics.
50
+ * Missing tags and `*` both become `media:`.
51
+ *
52
+ * @param {TaggedUrn} taggedUrn - Parsed tagged URN
53
+ * @param {string} tagName - `in` or `out`
54
+ * @returns {string} Normalized direction spec
48
55
  */
49
- function isValidMediaUrnOrWildcard(value) {
50
- return value === '*' || (value && value.startsWith('media:'));
56
+ function processDirectionTag(taggedUrn, tagName) {
57
+ const rawValue = taggedUrn.getTag(tagName);
58
+ if (rawValue === undefined || rawValue === '*') {
59
+ return 'media:';
60
+ }
61
+ if (rawValue === '') {
62
+ throw new CapUrnError(
63
+ tagName === 'in' ? ErrorCodes.INVALID_IN_SPEC : ErrorCodes.INVALID_OUT_SPEC,
64
+ `Empty value for '${tagName}' tag is not allowed`
65
+ );
66
+ }
67
+ return rawValue;
68
+ }
69
+
70
+ /**
71
+ * Canonicalize a direction spec via MediaUrn parsing.
72
+ *
73
+ * @param {string} spec - Media URN string
74
+ * @param {string} tagName - `in` or `out`
75
+ * @returns {string} Canonical media URN string
76
+ */
77
+ function canonicalizeDirectionSpec(spec, tagName) {
78
+ if (spec === 'media:' || spec === '*') {
79
+ return spec;
80
+ }
81
+
82
+ try {
83
+ return MediaUrn.fromString(spec).toString();
84
+ } catch (error) {
85
+ throw new CapUrnError(
86
+ tagName === 'in' ? ErrorCodes.INVALID_IN_SPEC : ErrorCodes.INVALID_OUT_SPEC,
87
+ `Invalid media URN for ${tagName} spec '${spec}': ${error.message}`
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Validate a direction spec while preserving the caller-provided string.
94
+ * This matches the reference `with_in_spec` / `with_out_spec` behavior.
95
+ *
96
+ * @param {string} spec - Media URN string
97
+ * @param {string} tagName - `in` or `out`
98
+ * @returns {string} The original spec when valid
99
+ */
100
+ function validatePreservedDirectionSpec(spec, tagName) {
101
+ if (spec === 'media:' || spec === '*') {
102
+ return spec;
103
+ }
104
+
105
+ try {
106
+ MediaUrn.fromString(spec);
107
+ return spec;
108
+ } catch (error) {
109
+ throw new CapUrnError(
110
+ tagName === 'in' ? ErrorCodes.INVALID_IN_SPEC : ErrorCodes.INVALID_OUT_SPEC,
111
+ `Invalid media URN for ${tagName} spec '${spec}': ${error.message}`
112
+ );
113
+ }
51
114
  }
52
115
 
53
116
  class CapUrn {
54
117
  /**
55
- * Create a new CapUrn with required direction specs
56
- * @param {string} inSpec - Required input media URN (e.g., "media:void") or wildcard "*"
57
- * @param {string} outSpec - Required output media URN (e.g., "media:object") or wildcard "*"
118
+ * Create a new CapUrn with direction specs.
119
+ * @param {string} inSpec - Input media URN (e.g., "media:void")
120
+ * @param {string} outSpec - Output media URN (e.g., "media:object")
58
121
  * @param {Object} tags - Other tags (must NOT contain 'in' or 'out')
59
122
  */
60
123
  constructor(inSpec, outSpec, tags = {}) {
61
- // Validate in/out are media URNs or wildcards
62
- if (!isValidMediaUrnOrWildcard(inSpec)) {
63
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
64
- }
65
- if (!isValidMediaUrnOrWildcard(outSpec)) {
66
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
67
- }
68
-
69
- this.inSpec = inSpec;
70
- this.outSpec = outSpec;
124
+ this.inSpec = canonicalizeDirectionSpec(inSpec, 'in');
125
+ this.outSpec = canonicalizeDirectionSpec(outSpec, 'out');
71
126
  this.tags = {};
72
127
  // Copy tags, filtering out any 'in' or 'out' that might have slipped through
73
128
  for (const [key, value] of Object.entries(tags)) {
@@ -116,13 +171,14 @@ class CapUrn {
116
171
  * Create a Cap URN from string representation
117
172
  * Format: cap:in="<media-urn>";out="<media-urn>";key1=value1;key2=value2;...
118
173
  *
119
- * IMPORTANT: 'in' and 'out' tags are REQUIRED and must be valid media URNs.
174
+ * Missing `in` / `out` default to `media:`. `in=*` / `out=*` are also
175
+ * normalized to `media:`.
120
176
  *
121
177
  * Uses TaggedUrn for parsing to ensure consistent behavior across implementations.
122
178
  *
123
179
  * @param {string} s - The Cap URN string
124
180
  * @returns {CapUrn} The parsed Cap URN
125
- * @throws {CapUrnError} If parsing fails or in/out are missing/invalid
181
+ * @throws {CapUrnError} If parsing fails or direction specs are invalid
126
182
  */
127
183
  static fromString(s) {
128
184
  if (!s || typeof s !== 'string') {
@@ -162,24 +218,8 @@ class CapUrn {
162
218
  throw new CapUrnError(ErrorCodes.MISSING_CAP_PREFIX, `Expected 'cap:' prefix, got '${taggedUrn.getPrefix()}:'`);
163
219
  }
164
220
 
165
- // Extract required 'in' and 'out' tags
166
- const inSpec = taggedUrn.getTag('in');
167
- const outSpec = taggedUrn.getTag('out');
168
-
169
- if (!inSpec) {
170
- throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' tag for input media URN");
171
- }
172
- if (!outSpec) {
173
- throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
174
- }
175
-
176
- // Validate in/out are media URNs or wildcards
177
- if (!isValidMediaUrnOrWildcard(inSpec)) {
178
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
179
- }
180
- if (!isValidMediaUrnOrWildcard(outSpec)) {
181
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
182
- }
221
+ const inSpec = processDirectionTag(taggedUrn, 'in');
222
+ const outSpec = processDirectionTag(taggedUrn, 'out');
183
223
 
184
224
  // Build remaining tags (excluding in/out)
185
225
  const remainingTags = {};
@@ -193,12 +233,12 @@ class CapUrn {
193
233
  }
194
234
 
195
235
  /**
196
- * Create a Cap URN from a tags object
197
- * Extracts 'in' and 'out' from tags (required), stores rest as regular tags
236
+ * Create a Cap URN from a tags object.
237
+ * Unlike string parsing, this path requires explicit `in` and `out` tags.
198
238
  *
199
239
  * @param {Object} tags - Object containing all tags including 'in' and 'out'
200
240
  * @returns {CapUrn} The parsed Cap URN
201
- * @throws {CapUrnError} If 'in' or 'out' tags are missing or invalid
241
+ * @throws {CapUrnError} If `in` or `out` tags are missing or invalid
202
242
  */
203
243
  static fromTags(tags) {
204
244
  const inSpec = tags['in'] || tags['IN'];
@@ -211,12 +251,11 @@ class CapUrn {
211
251
  throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
212
252
  }
213
253
 
214
- // Validate in/out are media URNs or wildcards
215
- if (!isValidMediaUrnOrWildcard(inSpec)) {
216
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
254
+ if (inSpec === '') {
255
+ throw new CapUrnError(ErrorCodes.INVALID_IN_SPEC, "Empty value for 'in' tag is not allowed");
217
256
  }
218
- if (!isValidMediaUrnOrWildcard(outSpec)) {
219
- throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
257
+ if (outSpec === '') {
258
+ throw new CapUrnError(ErrorCodes.INVALID_OUT_SPEC, "Empty value for 'out' tag is not allowed");
220
259
  }
221
260
 
222
261
  // Build remaining tags (excluding in/out)
@@ -289,15 +328,18 @@ class CapUrn {
289
328
  }
290
329
 
291
330
  /**
292
- * Create a new cap URN with an added or updated tag
293
- * Key is normalized to lowercase; value is preserved as-is
294
- * SILENTLY IGNORES attempts to set "in" or "out" - use withInSpec/withOutSpec instead
331
+ * Create a new cap URN with an added or updated tag.
332
+ * Attempts to set `in` / `out` through `withTag` are ignored; use
333
+ * `withInSpec` / `withOutSpec` instead.
295
334
  *
296
335
  * @param {string} key - The tag key
297
336
  * @param {string} value - The tag value
298
337
  * @returns {CapUrn} A new CapUrn instance with the tag added/updated
299
338
  */
300
339
  withTag(key, value) {
340
+ if (value === '') {
341
+ throw new CapUrnError(ErrorCodes.EMPTY_VALUE, `Empty value for key '${key}' (use '*' for wildcard)`);
342
+ }
301
343
  const keyLower = key.toLowerCase();
302
344
  // Silently ignore attempts to set in/out via withTag
303
345
  if (keyLower === 'in' || keyLower === 'out') {
@@ -315,7 +357,9 @@ class CapUrn {
315
357
  * @returns {CapUrn} A new CapUrn instance with the updated inSpec
316
358
  */
317
359
  withInSpec(inSpec) {
318
- return new CapUrn(inSpec, this.outSpec, this.tags);
360
+ const updated = new CapUrn(this.inSpec, this.outSpec, this.tags);
361
+ updated.inSpec = validatePreservedDirectionSpec(inSpec, 'in');
362
+ return updated;
319
363
  }
320
364
 
321
365
  /**
@@ -325,7 +369,9 @@ class CapUrn {
325
369
  * @returns {CapUrn} A new CapUrn instance with the updated outSpec
326
370
  */
327
371
  withOutSpec(outSpec) {
328
- return new CapUrn(this.inSpec, outSpec, this.tags);
372
+ const updated = new CapUrn(this.inSpec, this.outSpec, this.tags);
373
+ updated.outSpec = validatePreservedDirectionSpec(outSpec, 'out');
374
+ return updated;
329
375
  }
330
376
 
331
377
  /**
@@ -366,11 +412,9 @@ class CapUrn {
366
412
  return true;
367
413
  }
368
414
 
369
- // Direction specs: TaggedUrn semantic matching via MediaUrn
370
- // Check in_urn: cap's input spec (pattern) accepts request's input (instance).
371
- // "media:" on the PATTERN side (this.inSpec) means "I accept any input" skip check.
372
- // "*" is also treated as wildcard. "media:" on the instance side still participates.
373
- if (this.inSpec !== '*' && this.inSpec !== 'media:' && request.inSpec !== '*') {
415
+ // Input direction: pattern accepts instance. `media:` on the pattern side is
416
+ // the wildcard top and skips the check.
417
+ if (this.inSpec !== 'media:' && this.inSpec !== '*') {
374
418
  const capIn = TaggedUrn.fromString(this.inSpec);
375
419
  const requestIn = TaggedUrn.fromString(request.inSpec);
376
420
  if (!capIn.accepts(requestIn)) {
@@ -378,10 +422,9 @@ class CapUrn {
378
422
  }
379
423
  }
380
424
 
381
- // Check out_urn: cap's output (instance) conforms to request's output (pattern).
382
- // "media:" on the PATTERN side (this.outSpec) means "I accept any output" — skip check.
383
- // "*" is also treated as wildcard. "media:" on the instance side still participates.
384
- if (this.outSpec !== '*' && this.outSpec !== 'media:' && request.outSpec !== '*') {
425
+ // Output direction: provider output must conform to requested output.
426
+ // `media:` on the pattern side is wildcard top and skips the check.
427
+ if (this.outSpec !== 'media:' && this.outSpec !== '*') {
385
428
  const capOut = TaggedUrn.fromString(this.outSpec);
386
429
  const requestOut = TaggedUrn.fromString(request.outSpec);
387
430
  if (!capOut.conformsTo(requestOut)) {
@@ -389,33 +432,23 @@ class CapUrn {
389
432
  }
390
433
  }
391
434
 
392
- // Check all other tags that the request specifies
393
- for (const [requestKey, requestValue] of Object.entries(request.tags)) {
394
- const capValue = this.tags[requestKey];
395
-
396
- if (capValue === undefined) {
397
- // Missing tag in cap is treated as wildcard - can handle any value
398
- continue;
399
- }
435
+ // Check all tags required by the pattern. Missing tags in the instance reject.
436
+ for (const [patternKey, patternValue] of Object.entries(this.tags)) {
437
+ const requestValue = request.tags[patternKey];
400
438
 
401
- if (capValue === '*') {
402
- // Cap has wildcard - can handle any value
403
- continue;
439
+ if (requestValue === undefined) {
440
+ return false;
404
441
  }
405
442
 
406
- if (requestValue === '*') {
407
- // Request accepts any value - cap's specific value matches
443
+ if (patternValue === '*' || requestValue === '*') {
408
444
  continue;
409
445
  }
410
446
 
411
- if (capValue !== requestValue) {
412
- // Cap has specific value that doesn't match request's specific value
447
+ if (patternValue !== requestValue) {
413
448
  return false;
414
449
  }
415
450
  }
416
451
 
417
- // If cap has additional specific tags that request doesn't specify, that's fine
418
- // The cap is just more specific than needed
419
452
  return true;
420
453
  }
421
454
 
@@ -441,12 +474,13 @@ class CapUrn {
441
474
  */
442
475
  specificity() {
443
476
  let count = 0;
444
- // Direction specs contribute their MediaUrn tag count
445
- if (this.inSpec !== '*') {
477
+ // Direction specs contribute their MediaUrn tag count. `media:` is the
478
+ // wildcard top and contributes zero.
479
+ if (this.inSpec !== 'media:' && this.inSpec !== '*') {
446
480
  const inMedia = TaggedUrn.fromString(this.inSpec);
447
481
  count += Object.keys(inMedia.tags).length;
448
482
  }
449
- if (this.outSpec !== '*') {
483
+ if (this.outSpec !== 'media:' && this.outSpec !== '*') {
450
484
  const outMedia = TaggedUrn.fromString(this.outSpec);
451
485
  count += Object.keys(outMedia.tags).length;
452
486
  }
@@ -686,7 +720,7 @@ class CapMatcher {
686
720
  let bestSpecificity = -1;
687
721
 
688
722
  for (const cap of caps) {
689
- if (cap.accepts(request)) {
723
+ if (request.accepts(cap)) {
690
724
  const specificity = cap.specificity();
691
725
  if (specificity > bestSpecificity) {
692
726
  best = cap;
@@ -706,7 +740,7 @@ class CapMatcher {
706
740
  * @returns {CapUrn[]} Array of matching caps sorted by specificity (most specific first)
707
741
  */
708
742
  static findAllMatches(caps, request) {
709
- const matches = caps.filter(cap => cap.accepts(request));
743
+ const matches = caps.filter(cap => request.accepts(cap));
710
744
 
711
745
  // Sort by specificity (most specific first)
712
746
  matches.sort((a, b) => b.specificity() - a.specificity());
@@ -900,6 +934,8 @@ const MEDIA_TEXTABLE_PAGE = 'media:textable;page';
900
934
  // Collection types
901
935
  const MEDIA_COLLECTION = 'media:collection;record';
902
936
  const MEDIA_COLLECTION_LIST = 'media:collection;list;record';
937
+ // Media URN for adapter selection output - JSON record
938
+ const MEDIA_ADAPTER_SELECTION = 'media:adapter-selection;json;record';
903
939
 
904
940
  // =============================================================================
905
941
  // STANDARD CAP URN CONSTANTS
@@ -909,6 +945,10 @@ const MEDIA_COLLECTION_LIST = 'media:collection;list;record';
909
945
  // Accepts any media type as input and outputs any media type
910
946
  const CAP_IDENTITY = 'cap:in=media:;out=media:';
911
947
 
948
+ // Adapter-selection capability. Default implementation returns empty END (no match).
949
+ // Cartridges that inspect file content override this with a handler that returns {"media_urns": [...]}.
950
+ const CAP_ADAPTER_SELECTION = 'cap:in="media:";out="media:adapter-selection;json;record"';
951
+
912
952
  // =============================================================================
913
953
  // MEDIA URN CLASS
914
954
  // =============================================================================
@@ -2473,6 +2513,23 @@ function validateCapArgs(cap) {
2473
2513
  }
2474
2514
  }
2475
2515
 
2516
+ // RULE11: Stdin source consistency with in= spec
2517
+ // If in= is media:void, no args may have stdin sources.
2518
+ // If in= is anything other than media:void, at least one arg must have a stdin source.
2519
+ const inMediaUrn = cap.urn.inMediaUrn();
2520
+ const voidUrn = MediaUrn.fromString(MEDIA_VOID);
2521
+ const inIsVoid = inMediaUrn.isEquivalent(voidUrn);
2522
+ if (inIsVoid && stdinUrns.length > 0) {
2523
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2524
+ issue: `RULE11: Cap has in="${MEDIA_VOID}" but argument(s) declare stdin source`
2525
+ });
2526
+ }
2527
+ if (!inIsVoid && stdinUrns.length === 0 && args.length > 0) {
2528
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2529
+ issue: `RULE11: Cap has non-void in= spec but no argument declares a stdin source`
2530
+ });
2531
+ }
2532
+
2476
2533
  // RULE5: No two args may have same position
2477
2534
  const positionSet = new Set();
2478
2535
  for (const { position, mediaUrn } of positions) {
@@ -2507,9 +2564,6 @@ function validateCapArgs(cap) {
2507
2564
  flagSet.add(flag);
2508
2565
  }
2509
2566
 
2510
- // RULE8: No unknown keys in source objects - this is handled in ArgSource.fromJSON()
2511
- // RULE11: cli_flag used verbatim as specified - enforced by design
2512
- // RULE12: media_urn is the key, no name field - enforced by CapArg structure
2513
2567
  }
2514
2568
 
2515
2569
  /**
@@ -5628,6 +5682,9 @@ module.exports = {
5628
5682
  // Collection types
5629
5683
  MEDIA_COLLECTION,
5630
5684
  MEDIA_COLLECTION_LIST,
5685
+ MEDIA_ADAPTER_SELECTION,
5686
+ // Standard cap URN constants
5687
+ CAP_ADAPTER_SELECTION,
5631
5688
  // Cap execution result
5632
5689
  CapResult,
5633
5690
  // Unified argument type