isc-transforms-mcp 1.0.11 → 1.0.13

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "sailpoint.isc.transforms.dateMath.schema.json",
4
4
  "title": "SailPoint ISC Transform Schema - dateMath",
5
- "description": "Strict schema derived from the SailPoint official Date Math operation documentation. Adds/subtracts/rounds components of an incoming ISO8601 UTC timestamp, or uses the keyword 'now'. Other considerations from docs: input must be ISO8601 UTC; rounding by 'week' is not supported; if 'now' is used and an 'input' attribute is also provided, the transform prefers 'now' and ignores 'input'. Output format is yyyy-MM-dd'T'HH:mm (not ISO8601); convert to ISO8601 via dateFormat when embedding in other transforms.",
5
+ "description": "Strict schema for the SailPoint ISC Date Math transform. Adds, subtracts, and rounds components of a datetime using 'now' or an explicit ISO8601 UTC input. IMPORTANT CONSTRAINTS: (1) Input must be ISO8601 UTC format (yyyy-MM-dd'T'HH:mm:ss.SSSZ). (2) Output format is 'yyyy-MM-dd\\'T\\'HH:mm' NOT full ISO8601; wrap with dateFormat outputFormat: ISO8601 when nesting in other transforms. (3) Week rounding (/w) is not supported. (4) If expression uses 'now' and an input attribute is also provided, 'now' takes precedence and input is ignored.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -12,16 +12,18 @@
12
12
  ],
13
13
  "properties": {
14
14
  "type": {
15
- "const": "dateMath"
15
+ "const": "dateMath",
16
+ "description": "Transform operation type. Must be exactly 'dateMath'."
16
17
  },
17
18
  "name": {
18
19
  "type": "string",
19
- "minLength": 1
20
+ "minLength": 1,
21
+ "description": "Display name for this transform, shown in UI dropdowns and identity profile mappings."
20
22
  },
21
23
  "requiresPeriodicRefresh": {
22
24
  "type": "boolean",
23
25
  "default": false,
24
- "description": "Whether the transform logic should be reevaluated nightly as part of identity refresh. Default false."
26
+ "description": "If true, re-evaluates this transform during the nightly identity refresh cycle. RECOMMENDED when expression uses 'now', so the computed date stays current. Default is false."
25
27
  },
26
28
  "attributes": {
27
29
  "type": "object",
@@ -33,21 +35,17 @@
33
35
  "expression": {
34
36
  "type": "string",
35
37
  "minLength": 1,
36
- "description": "Date-math expression. Allowed units: y, M, w, d, h, m, s and keyword 'now'. Allowed operators: +, -, / (round). Docs note rounding with week is not supported.",
37
- "pattern": "^(?!.*\\/w)(?:now|[0-9yMwdhms+\\-\\/]+)$"
38
+ "pattern": "^(?!\\.*/w)(now[+\\-\\/0-9yMwdhms]*|[+\\-\\/][0-9yMwdhms+\\-\\/]*)$",
39
+ "description": "Date-math expression defining the operation. Valid time units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second). Keyword 'now' = current datetime (must be at start). Operators: +(add) -(subtract) /(round — must be last, NOT /w). No whitespace allowed. Examples: 'now' | 'now/h' | 'now-5d/d' | 'now+1w' | '+3M' | '+12h/s' | 'now+1y+1M+2d-4h+1m-3s/s'. NOTE: output is yyyy-MM-dd'T'HH:mm — wrap with dateFormat for ISO8601."
38
40
  },
39
41
  "roundUp": {
40
42
  "type": "boolean",
41
43
  "default": false,
42
- "description": "When expression includes rounding '/', true rounds up (truncate + add one unit); false truncates only. Default false."
44
+ "description": "Controls rounding direction when the '/' operator is used. true = round up (truncate to unit then add one unit). false = round down (truncate only, default). Has NO effect if expression does not contain '/'."
43
45
  },
44
46
  "input": {
45
- "description": "Explicitly defines the input data passed into the transform (nested transform object). If omitted, uses UI-configured input.",
46
- "oneOf": [
47
- {
48
- "$ref": "#/$defs/NestedTransform"
49
- }
50
- ]
47
+ "description": "Nested transform providing the input datetime. Output of the nested transform must be ISO8601 UTC (yyyy-MM-dd'T'HH:mm:ss.SSSZ). Use a dateFormat transform with outputFormat: 'ISO8601' if the source attribute is not already ISO8601. IGNORED if expression contains 'now'.",
48
+ "$ref": "#/$defs/NestedTransform"
51
49
  }
52
50
  }
53
51
  }
@@ -55,7 +53,7 @@
55
53
  "$defs": {
56
54
  "NestedTransform": {
57
55
  "type": "object",
58
- "description": "Nested transform object used as explicit input. Docs examples frequently omit 'name' for nested transforms.",
56
+ "description": "A nested transform object whose output is the input datetime for this dateMath transform. Must produce an ISO8601 UTC string. Commonly wraps an accountAttribute through a dateFormat transform. The 'name' field is optional in nested transforms per SailPoint docs.",
59
57
  "additionalProperties": false,
60
58
  "required": [
61
59
  "type",
@@ -63,18 +61,22 @@
63
61
  ],
64
62
  "properties": {
65
63
  "id": {
66
- "type": "string"
64
+ "type": "string",
65
+ "description": "Optional ID when referencing an existing saved transform."
67
66
  },
68
67
  "name": {
69
68
  "type": "string",
70
- "minLength": 1
69
+ "minLength": 1,
70
+ "description": "Optional display name for the nested transform."
71
71
  },
72
72
  "type": {
73
73
  "type": "string",
74
- "minLength": 1
74
+ "minLength": 1,
75
+ "description": "The operation type of the nested transform (e.g., 'dateFormat', 'accountAttribute')."
75
76
  },
76
77
  "requiresPeriodicRefresh": {
77
- "type": "boolean"
78
+ "type": "boolean",
79
+ "description": "Whether this nested transform re-evaluates during nightly refresh."
78
80
  },
79
81
  "attributes": {
80
82
  "type": "object",
@@ -86,16 +88,33 @@
86
88
  },
87
89
  "examples": [
88
90
  {
91
+ "name": "Five Days Ago Rounded to Day",
89
92
  "type": "dateMath",
90
- "name": "Date Math Transform",
93
+ "requiresPeriodicRefresh": true,
91
94
  "attributes": {
92
95
  "expression": "now-5d/d",
93
96
  "roundUp": false
94
97
  }
95
98
  },
96
99
  {
100
+ "name": "Current Time Rounded to Hour",
101
+ "type": "dateMath",
102
+ "requiresPeriodicRefresh": true,
103
+ "attributes": {
104
+ "expression": "now/h"
105
+ }
106
+ },
107
+ {
108
+ "name": "One Week From Now",
109
+ "type": "dateMath",
110
+ "requiresPeriodicRefresh": true,
111
+ "attributes": {
112
+ "expression": "now+1w"
113
+ }
114
+ },
115
+ {
116
+ "name": "Add 12 Hours to Source Date Rounded to Second",
97
117
  "type": "dateMath",
98
- "name": "Date Math Transform (Explicit Input)",
99
118
  "attributes": {
100
119
  "expression": "+12h/s",
101
120
  "roundUp": true,
@@ -114,6 +133,14 @@
114
133
  }
115
134
  }
116
135
  }
136
+ },
137
+ {
138
+ "name": "Complex: Add 1 Year 1 Month 2 Days Minus 4 Hours Rounded to Second",
139
+ "type": "dateMath",
140
+ "requiresPeriodicRefresh": true,
141
+ "attributes": {
142
+ "expression": "now+1y+1M+2d-4h+1m-3s/s"
143
+ }
117
144
  }
118
145
  ]
119
146
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "sailpoint.isc.transforms.firstValid.schema.json",
4
4
  "title": "SailPoint ISC Transform Schema - firstValid",
5
- "description": "Strict schema derived from SailPoint official First Valid operation documentation. Returns the first non-null value from a list of values/transforms. Optional ignoreErrors controls whether evaluation errors are ignored.",
5
+ "description": "Strict schema for the SailPoint ISC First Valid transform. Evaluates an ordered array of values or nested transforms and returns the first entry that produces a non-null result. Entries are evaluated left-to-right; the highest-priority source should be listed first with a static string fallback last. ignoreErrors controls whether evaluation errors (e.g., NPE on missing manager) are skipped or thrown.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -12,16 +12,18 @@
12
12
  ],
13
13
  "properties": {
14
14
  "type": {
15
- "const": "firstValid"
15
+ "const": "firstValid",
16
+ "description": "Transform operation type. Must be exactly 'firstValid'."
16
17
  },
17
18
  "name": {
18
19
  "type": "string",
19
- "minLength": 1
20
+ "minLength": 1,
21
+ "description": "Display name for this transform, shown in UI dropdowns and identity profile mappings."
20
22
  },
21
23
  "requiresPeriodicRefresh": {
22
24
  "type": "boolean",
23
25
  "default": false,
24
- "description": "Whether the transform logic should be reevaluated nightly as part of identity refresh. Default false."
26
+ "description": "If true, re-evaluates this transform during the nightly identity refresh cycle. Default is false."
25
27
  },
26
28
  "attributes": {
27
29
  "type": "object",
@@ -33,29 +35,12 @@
33
35
  "values": {
34
36
  "type": "array",
35
37
  "minItems": 1,
36
- "description": "Ordered list of values/transforms. The first non-null result is returned.",
38
+ "description": "Ordered list of values to evaluate. The first entry that produces a non-null result is returned. Entries are evaluated left-to-right — place highest-priority sources first and include a static string as the last entry to guarantee a fallback. Each entry must be a static string or a nested transform object.",
37
39
  "items": {
38
- "oneOf": [
40
+ "anyOf": [
39
41
  {
40
- "type": "string"
41
- },
42
- {
43
- "type": "number"
44
- },
45
- {
46
- "type": "integer"
47
- },
48
- {
49
- "type": "boolean"
50
- },
51
- {
52
- "type": "array"
53
- },
54
- {
55
- "type": "object"
56
- },
57
- {
58
- "type": "null"
42
+ "type": "string",
43
+ "description": "Static string value. Use as a fallback (e.g., 'none', 'N/A') or a fixed value option."
59
44
  },
60
45
  {
61
46
  "$ref": "#/$defs/NestedTransform"
@@ -66,7 +51,7 @@
66
51
  "ignoreErrors": {
67
52
  "type": "boolean",
68
53
  "default": false,
69
- "description": "If true, errors from evaluating a value/transform are ignored and evaluation continues. Default false."
54
+ "description": "Controls error handling during evaluation. false (default) = throw on errors such as null pointer exceptions (e.g., accessing a manager attribute for a user with no manager). true = catch errors silently and move to the next entry in values. Set to true when any entry may throw on some identities."
70
55
  }
71
56
  }
72
57
  }
@@ -74,7 +59,7 @@
74
59
  "$defs": {
75
60
  "NestedTransform": {
76
61
  "type": "object",
77
- "description": "Nested transform object used inside values[]; docs examples often omit 'name' for nested transforms.",
62
+ "description": "A nested transform object used as an entry in the values array. Must have a 'type' field. Common types: accountAttribute, identityAttribute, static, getReferenceIdentityAttribute. The 'name' field is optional in nested transforms per SailPoint docs.",
78
63
  "additionalProperties": false,
79
64
  "required": [
80
65
  "type",
@@ -82,18 +67,22 @@
82
67
  ],
83
68
  "properties": {
84
69
  "id": {
85
- "type": "string"
70
+ "type": "string",
71
+ "description": "Optional ID when referencing an existing saved transform."
86
72
  },
87
73
  "name": {
88
74
  "type": "string",
89
- "minLength": 1
75
+ "minLength": 1,
76
+ "description": "Optional display name for the nested transform."
90
77
  },
91
78
  "type": {
92
79
  "type": "string",
93
- "minLength": 1
80
+ "minLength": 1,
81
+ "description": "The operation type of the nested transform (e.g., 'accountAttribute', 'identityAttribute', 'static', 'getReferenceIdentityAttribute')."
94
82
  },
95
83
  "requiresPeriodicRefresh": {
96
- "type": "boolean"
84
+ "type": "boolean",
85
+ "description": "Whether this nested transform re-evaluates during nightly refresh."
97
86
  },
98
87
  "attributes": {
99
88
  "type": "object",
@@ -105,8 +94,8 @@
105
94
  },
106
95
  "examples": [
107
96
  {
97
+ "name": "Preferred Name with Fallback",
108
98
  "type": "firstValid",
109
- "name": "First Valid Transform",
110
99
  "attributes": {
111
100
  "values": [
112
101
  {
@@ -120,10 +109,54 @@
120
109
  "attributes": {
121
110
  "name": "givenName"
122
111
  }
123
- }
112
+ },
113
+ "Unknown"
124
114
  ],
125
115
  "ignoreErrors": false
126
116
  }
117
+ },
118
+ {
119
+ "name": "Work Email Fallback to Personal Email",
120
+ "type": "firstValid",
121
+ "attributes": {
122
+ "values": [
123
+ {
124
+ "type": "accountAttribute",
125
+ "attributes": {
126
+ "sourceName": "Active Directory",
127
+ "attributeName": "mail"
128
+ }
129
+ },
130
+ {
131
+ "type": "accountAttribute",
132
+ "attributes": {
133
+ "sourceName": "HR Source",
134
+ "attributeName": "personal_email"
135
+ }
136
+ },
137
+ "no-email@example.com"
138
+ ],
139
+ "ignoreErrors": true
140
+ }
141
+ },
142
+ {
143
+ "name": "Manager Email with NPE Protection",
144
+ "type": "firstValid",
145
+ "attributes": {
146
+ "values": [
147
+ {
148
+ "type": "getReferenceIdentityAttribute",
149
+ "attributes": {
150
+ "name": "Cloud Services Deployment Utility",
151
+ "operation": "getReferenceIdentityAttribute",
152
+ "uid": "manager",
153
+ "attributeName": "email"
154
+ }
155
+ },
156
+ "no-manager@example.com"
157
+ ],
158
+ "ignoreErrors": true
159
+ }
127
160
  }
128
161
  ]
129
162
  }
@@ -26,7 +26,7 @@ const ALLOWED_ATTRS = {
26
26
  decomposeDiacriticalMarks: new Set(["input"]),
27
27
  displayName: new Set(["input"]),
28
28
  e164phone: new Set(["input", "defaultRegion"]),
29
- firstValid: new Set(["values", "input"]),
29
+ firstValid: new Set(["values", "ignoreErrors"]),
30
30
  identityAttribute: new Set(["name", "input"]),
31
31
  indexOf: new Set(["substring", "input"]),
32
32
  iso3166: new Set(["format", "input"]),
@@ -349,7 +349,86 @@ function lintConditional(attrs) {
349
349
  return msgs;
350
350
  }
351
351
  // ---------------------------------------------------------------------------
352
- // 7. replacevalidate regex compiles
352
+ // 7. firstValidvalues array + ignoreErrors
353
+ // ---------------------------------------------------------------------------
354
+ function lintFirstValid(attrs) {
355
+ const msgs = [];
356
+ const values = attrs?.values;
357
+ // --- 1. values: required non-empty array (schema enforces, lint gives richer message) ---
358
+ if (values === undefined || values === null) {
359
+ push(msgs, "error", "values is required for firstValid. Provide an ordered array of strings or nested transforms — first non-null result is returned.", "attributes.values");
360
+ return msgs;
361
+ }
362
+ if (!Array.isArray(values)) {
363
+ push(msgs, "error", "values must be an array of strings and/or nested transform objects.", "attributes.values");
364
+ return msgs;
365
+ }
366
+ if (values.length === 0) {
367
+ push(msgs, "error", "values array must not be empty. Provide at least one string or nested transform.", "attributes.values");
368
+ return msgs;
369
+ }
370
+ // --- 2. Warn if only one value — firstValid is pointless with a single entry ---
371
+ if (values.length === 1) {
372
+ push(msgs, "warn", "values array has only one entry. firstValid is designed to fall back across multiple options — " +
373
+ "consider adding additional fallback values or using a simpler transform.", "attributes.values");
374
+ }
375
+ // --- 3. Validate each item: must be string or nested transform object ---
376
+ values.forEach((item, idx) => {
377
+ if (item === null || item === undefined) {
378
+ push(msgs, "warn", `values[${idx}] is null/undefined — this entry will always be skipped. Remove it or replace with a static string fallback.`, `attributes.values[${idx}]`);
379
+ }
380
+ else if (typeof item === "string") {
381
+ // strings are valid — no error
382
+ }
383
+ else if (isPlainObject(item)) {
384
+ if (typeof item.type !== "string" || item.type.trim() === "") {
385
+ push(msgs, "error", `values[${idx}] is an object but is missing a 'type' field — it does not look like a valid nested transform. ` +
386
+ "Add a 'type' (e.g., 'accountAttribute', 'identityAttribute', 'static').", `attributes.values[${idx}]`);
387
+ }
388
+ }
389
+ else {
390
+ push(msgs, "error", `values[${idx}] must be a string or a nested transform object {type, attributes}. ` +
391
+ `Got: ${typeof item}.`, `attributes.values[${idx}]`);
392
+ }
393
+ });
394
+ // --- 4. Recommend a string fallback as the last entry ---
395
+ const lastItem = values[values.length - 1];
396
+ if (values.length > 1 && typeof lastItem !== "string") {
397
+ push(msgs, "info", "Consider making the last entry in values a static string fallback (e.g., 'none', 'N/A') " +
398
+ "to guarantee a non-null result when all other values are unavailable.", "attributes.values");
399
+ }
400
+ // --- 5. ignoreErrors: boolean check + semantics info ---
401
+ if (attrs?.ignoreErrors !== undefined) {
402
+ if (typeof attrs.ignoreErrors !== "boolean") {
403
+ push(msgs, "error", "ignoreErrors must be a boolean. " +
404
+ "true = skip values that throw errors (e.g., NPE on missing manager) and evaluate next entry. " +
405
+ "false = throw on errors (default).", "attributes.ignoreErrors");
406
+ }
407
+ else if (attrs.ignoreErrors === false) {
408
+ // Explicit false — check if any nested transforms reference identity attributes that could NPE
409
+ const hasReferenceTransforms = values.some((v) => isPlainObject(v) &&
410
+ ["identityAttribute", "accountAttribute", "getReferenceIdentityAttribute"].includes(v.type));
411
+ if (hasReferenceTransforms) {
412
+ push(msgs, "info", "ignoreErrors is false (default). If any entry references an attribute that doesn't exist on some identities " +
413
+ "(e.g., a manager attribute for users without managers), a null pointer exception will stop evaluation. " +
414
+ "Set ignoreErrors: true to safely skip failing entries.", "attributes.ignoreErrors");
415
+ }
416
+ }
417
+ }
418
+ else {
419
+ // ignoreErrors not set — same NPE risk hint if reference transforms present
420
+ const hasReferenceTransforms = values.some((v) => isPlainObject(v) &&
421
+ ["identityAttribute", "accountAttribute", "getReferenceIdentityAttribute"].includes(v.type));
422
+ if (hasReferenceTransforms) {
423
+ push(msgs, "info", "ignoreErrors defaults to false. If any entry may throw an error on some identities " +
424
+ "(e.g., accessing a manager attribute for users without managers), set ignoreErrors: true " +
425
+ "to skip failing entries instead of halting evaluation.", "attributes.ignoreErrors");
426
+ }
427
+ }
428
+ return msgs;
429
+ }
430
+ // ---------------------------------------------------------------------------
431
+ // 8. replace — validate regex compiles
353
432
  // ---------------------------------------------------------------------------
354
433
  function lintReplace(attrs) {
355
434
  const msgs = [];
@@ -399,27 +478,34 @@ function lintReplaceAll(attrs) {
399
478
  function lintDateMath(attrs) {
400
479
  const msgs = [];
401
480
  const expr = attrs?.expression;
481
+ let startsWithNow = false;
482
+ let sawRound = false;
402
483
  if (expr !== undefined) {
403
484
  if (typeof expr !== "string" || expr.trim().length === 0) {
404
485
  push(msgs, "error", "expression must be a non-empty string.", "attributes.expression");
405
486
  }
406
487
  else {
407
488
  const s = expr.trim();
489
+ // --- No whitespace allowed ---
408
490
  if (/\s/.test(s)) {
409
491
  push(msgs, "error", "expression must not contain whitespace.", "attributes.expression");
410
492
  }
493
+ // --- Valid character set: digits, y M w d h m s, n o w (for 'now'), +, -, / ---
411
494
  if (!/^[0-9yMwdhmsnow+\-/]+$/.test(s)) {
412
- push(msgs, "error", "expression contains invalid characters. Allowed: digits, y M w d h m s, now, +, -, /.", "attributes.expression");
495
+ push(msgs, "error", "expression contains invalid characters. " +
496
+ "Allowed units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second), keyword 'now', operators: + - /.", "attributes.expression");
413
497
  }
414
498
  let i = 0;
415
499
  const n = s.length;
416
- const startsWithNow = s.startsWith("now");
500
+ startsWithNow = s.startsWith("now");
417
501
  if (startsWithNow)
418
502
  i += 3;
503
+ // 'now' must only appear at the start
419
504
  if (!startsWithNow && s.includes("now")) {
420
- push(msgs, "error", "'now' keyword must appear only at the start of the expression.", "attributes.expression");
505
+ push(msgs, "error", "'now' keyword must appear only at the start of the expression (e.g., 'now-5d/d', 'now+1w').", "attributes.expression");
421
506
  }
422
- let sawOp = false, sawRound = false, roundUnit = null;
507
+ let sawOp = false;
508
+ let roundUnit = null;
423
509
  const readInt = () => {
424
510
  const start = i;
425
511
  while (i < n && /[0-9]/.test(s[i]))
@@ -438,19 +524,21 @@ function lintDateMath(attrs) {
438
524
  const readSignedTerm = (op) => {
439
525
  const num = readInt();
440
526
  if (!num) {
441
- push(msgs, "error", `Missing integer after '${op}' in expression.`, "attributes.expression");
527
+ push(msgs, "error", `Missing integer after '${op}' in expression. Example: '${op}3d'.`, "attributes.expression");
442
528
  return;
443
529
  }
444
- if (Number(num) === 0)
445
- push(msgs, "warn", `Term '${op}${num}' is a no-op (zero).`, "attributes.expression");
530
+ if (Number(num) === 0) {
531
+ push(msgs, "warn", `Term '${op}${num}' adds/subtracts zero — this is a no-op.`, "attributes.expression");
532
+ }
446
533
  const unit = readUnit();
447
- if (!unit)
448
- push(msgs, "error", `Missing unit after '${op}${num}'. Allowed: y M w d h m s.`, "attributes.expression");
534
+ if (!unit) {
535
+ push(msgs, "error", `Missing time unit after '${op}${num}'. Allowed units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second).`, "attributes.expression");
536
+ }
449
537
  };
450
538
  const readRound = () => {
451
539
  const unit = readUnit();
452
540
  if (!unit) {
453
- push(msgs, "error", "Rounding '/' must be followed by a unit (y M w d h m s).", "attributes.expression");
541
+ push(msgs, "error", "Rounding operator '/' must be followed by a time unit. Allowed: y M d h m s (NOT w — week rounding is unsupported).", "attributes.expression");
454
542
  return;
455
543
  }
456
544
  sawRound = true;
@@ -460,14 +548,14 @@ function lintDateMath(attrs) {
460
548
  i++;
461
549
  readRound();
462
550
  if (i < n)
463
- push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
551
+ push(msgs, "error", "Rounding '/' must be the last segment of the expression (e.g., 'now-5d/d').", "attributes.expression");
464
552
  }
465
553
  else {
466
554
  while (i < n) {
467
555
  const ch = s[i];
468
556
  if (ch === "+" || ch === "-") {
469
557
  if (sawRound) {
470
- push(msgs, "error", "Add/subtract terms cannot appear after rounding ('/').", "attributes.expression");
558
+ push(msgs, "error", "Add/subtract terms cannot appear after the rounding operator ('/').", "attributes.expression");
471
559
  break;
472
560
  }
473
561
  sawOp = true;
@@ -477,43 +565,75 @@ function lintDateMath(attrs) {
477
565
  }
478
566
  if (ch === "/") {
479
567
  if (sawRound) {
480
- push(msgs, "error", "Only one rounding operator '/' is supported.", "attributes.expression");
568
+ push(msgs, "error", "Only one rounding operator '/' is allowed per expression.", "attributes.expression");
481
569
  break;
482
570
  }
483
571
  i++;
484
572
  readRound();
485
573
  if (i < n)
486
- push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
574
+ push(msgs, "error", "Rounding '/' must be the last segment of the expression.", "attributes.expression");
487
575
  break;
488
576
  }
489
- push(msgs, "error", `Unexpected token '${ch}'. Use patterns like 'now-5d/d' or '+3M/h'.`, "attributes.expression");
577
+ push(msgs, "error", `Unexpected token '${ch}' in expression '${s}'. ` +
578
+ "Valid forms: 'now', 'now-5d/d', 'now+1y+1M', '+3M', '+12h/s'.", "attributes.expression");
490
579
  break;
491
580
  }
492
581
  }
582
+ // Expression must start with 'now', +, -, or /
493
583
  if (!startsWithNow && s !== "" && s[0] !== "+" && s[0] !== "-" && s[0] !== "/") {
494
- push(msgs, "error", "Expression must start with 'now', '+', '-', or '/'.", "attributes.expression");
584
+ push(msgs, "error", `Expression must start with 'now', '+', '-', or '/'. Got: '${s}'. ` +
585
+ "Examples: 'now-5d/d', '+3M', '-1y/d'.", "attributes.expression");
495
586
  }
496
- // Week rounding not supported (SailPoint docs)
587
+ // Week rounding is explicitly unsupported per docs
497
588
  if (roundUnit === "w") {
498
- push(msgs, "error", "Rounding with 'w' (week) is not supported in dateMath expressions.", "attributes.expression");
589
+ push(msgs, "error", "Rounding with 'w' (week) is not supported by SailPoint dateMath and will produce an error at runtime. " +
590
+ "Use a different unit for rounding (y, M, d, h, m, s).", "attributes.expression");
499
591
  }
592
+ // Expression without 'now' and no ops is invalid
500
593
  if (!startsWithNow && !sawOp && !sawRound) {
501
- push(msgs, "error", "Expression must contain 'now', at least one +/- term, or a rounding segment.", "attributes.expression");
594
+ push(msgs, "error", "Expression must contain 'now', at least one +/- term, or a rounding segment. " +
595
+ "Examples: 'now', 'now-5d/d', '+3M/h'.", "attributes.expression");
502
596
  }
597
+ // Expression without 'now' requires an input date
503
598
  if (!startsWithNow && attrs?.input === undefined) {
504
- push(msgs, "error", "dateMath expression without 'now' requires an input date transform in attributes.input.", "attributes.input");
599
+ push(msgs, "error", "dateMath expression without 'now' requires an explicit input date via attributes.input (nested transform). " +
600
+ "The input must produce an ISO8601 UTC datetime. Use a dateFormat transform with outputFormat: 'ISO8601' if needed.", "attributes.input");
601
+ }
602
+ // Output format info — dateMath output is yyyy-MM-dd'T'HH:mm, not full ISO8601
603
+ push(msgs, "info", "dateMath output format is 'yyyy-MM-dd\\'T\\'HH:mm' — this is NOT full ISO8601. " +
604
+ "If this transform feeds into another transform that expects ISO8601 (e.g., dateCompare), " +
605
+ "wrap it with a dateFormat transform using outputFormat: 'ISO8601'.", "attributes.expression");
606
+ // Recommend requiresPeriodicRefresh when 'now' is used
607
+ if (startsWithNow) {
608
+ push(msgs, "info", "Expression uses 'now'. Set requiresPeriodicRefresh: true at the transform root level " +
609
+ "so the date re-evaluates during nightly identity refresh — otherwise results may become stale.", "attributes.expression");
505
610
  }
506
611
  }
507
612
  }
613
+ // --- roundUp: boolean check ---
508
614
  if (attrs?.roundUp !== undefined && typeof attrs.roundUp !== "boolean") {
509
- push(msgs, "error", "roundUp must be a boolean.", "attributes.roundUp");
615
+ push(msgs, "error", "roundUp must be a boolean. true = round up (truncate + add one unit); false = truncate only (default).", "attributes.roundUp");
616
+ }
617
+ // --- roundUp without rounding operator has no effect ---
618
+ if (attrs?.roundUp === true &&
619
+ typeof expr === "string" &&
620
+ !expr.includes("/")) {
621
+ push(msgs, "warn", "roundUp is true but expression contains no rounding operator '/'. " +
622
+ "roundUp has no effect without '/'. Add a rounding segment (e.g., '/d') or remove roundUp.", "attributes.roundUp");
510
623
  }
624
+ // --- input: must be a nested transform object ---
511
625
  if (attrs?.input !== undefined) {
512
626
  const inp = attrs.input;
513
627
  if (!(inp && typeof inp === "object" && typeof inp.type === "string")) {
514
- push(msgs, "warn", "input should be a nested transform object {type, attributes}.", "attributes.input");
628
+ push(msgs, "warn", "input must be a nested transform object {type, attributes} that produces an ISO8601 UTC datetime. " +
629
+ "Use a dateFormat transform with outputFormat: 'ISO8601' if the source attribute is not already ISO8601.", "attributes.input");
515
630
  }
516
631
  }
632
+ // --- 'now' + input conflict warning ---
633
+ if (startsWithNow && attrs?.input !== undefined) {
634
+ push(msgs, "warn", "Expression uses 'now' and an input attribute is also provided. " +
635
+ "Per SailPoint docs, when 'now' is in the expression the transform ignores the input attribute entirely.", "attributes.input");
636
+ }
517
637
  return msgs;
518
638
  }
519
639
  // ---------------------------------------------------------------------------
@@ -997,6 +1117,8 @@ export function lintTransform(input) {
997
1117
  messages.push(...lintAccountAttribute(attrs));
998
1118
  if (requestedType === "conditional")
999
1119
  messages.push(...lintConditional(attrs));
1120
+ if (requestedType === "firstValid")
1121
+ messages.push(...lintFirstValid(attrs));
1000
1122
  if (requestedType === "replace")
1001
1123
  messages.push(...lintReplace(attrs));
1002
1124
  if (requestedType === "replaceAll")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isc-transforms-mcp",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "description": "MCP server for SailPoint Identity Security Cloud (ISC) Transform authoring — scaffold, strict lint, catalog, and safe upsert to live tenants.",
6
6
  "author": {